diff --git a/AUDIO_DOWNLOADS_BN.md b/AUDIO_DOWNLOADS_BN.md new file mode 100644 index 0000000..21de9db --- /dev/null +++ b/AUDIO_DOWNLOADS_BN.md @@ -0,0 +1,39 @@ +# SparkTube 1.4.1 — অডিও ডাউনলোড টেস্ট বিল্ড + +## নতুন কী + +Music → Downloads পেজে এখন শুধু সম্পন্ন গান নয়, চলমান, অপেক্ষমাণ, paused ও ব্যর্থ অডিও ডাউনলোডও দেখা যায়। ভিডিওর একই download adapter/engine দিয়ে pause/resume, progress bar, শতাংশ (মোট সাইজ জানা থাকলে), downloaded/total MB, MB/s এবং বাকি সময় দেখায়। অজানা মোট সাইজ বা সময়কে অনুমান করে দেখায় না। + +গানের সারিতে বর্গাকার album art, শিরোনাম ও artist আছে। উপরে গানের সংখ্যা ও সবুজ Play all রাখা হয়েছে। গান গোনা ও Play all-এর কিউতে শুধু সম্পূর্ণ এবং ডিস্কে থাকা অডিও যায়; আংশিক ফাইল বাজানো হয় না। সম্পন্ন গানের তিন-ডট মেনুতে Open, Share, Delete আছে; Delete-এ confirmation দেখায়। চলমান/ব্যর্থ সারি long-press করে মুছতে পারেন। পেজ খোলা থাকলে প্রতি সেকেন্ডে অগ্রগতি আপডেট হয়; অন্য পেজে গেলে polling বন্ধ হয়। + +## যা অপরিবর্তিত + +`com.sparktube.app`, versionName **1.4.1**, versionCode **10** এবং আগের টেস্ট APK-এর signing certificate একই। ভিডিও download engine, Library downloads, player ও compact Audio/Video toggle-এর সোর্স অপরিবর্তিত। DownloadAdapter-এ audioStyle opt-in; পুরোনো default video view আগের পথেই চলে। নতুন dependency, native library বা বড় image asset যোগ হয়নি। + +APK: **10,303,832 bytes** (10.30 MB); আগের compact-toggle APK থেকে পরিবর্তন **+2,432 bytes**। এটি debug-signed test build, store release নয়। + +## যাচাই ও সীমা + +বিল্ড সফল; **46 JVM tests** (অডিও লাইব্রেরির 8 নতুন test-সহ) এবং **15 source/XML structure checks** পাস। APK package/version, signature এবং ZIP alignment যাচাই করা হয়েছে। আগের editable ZIP-এর সঙ্গে মিলিয়ে পুরোনো app source-এ কেবল MusicListActivity ও DownloadAdapter বদলানোর বিষয়টি পরীক্ষা হয়েছে। এটি source-level regression evidence, runtime-এর নিশ্চয়তা নয়। + +কোনো Android ফোন বা emulator সংযুক্ত নেই। তাই আসল ফোনে install, screen rendering, download, pause/resume, Play all, playback, share/delete-এর end-to-end পরীক্ষা হয়নি। Android lint offline cache-এ প্রয়োজনীয় `lint-gradle:31.6.1` না থাকায় lintVital ধাপ বাদ দেওয়া হয়েছে; full lint সম্পন্ন হয়নি। আগের deprecation warnings আছে। + +আগের সীমাবদ্ধতা একই: pause/resume কেবল চলমান app process-এর মধ্যে; process kill বা restart-এর পর অসম্পূর্ণ download আবার শুরু করতে হবে। Range সমর্থন না করা server pause-এর সময় সংযোগ ছিন্ন করলে download ব্যর্থ হতে পারে। ব্যর্থ item long-press করে মুছে পুনরায় download করতে হবে; নতুন retry/restart button যোগ করা হয়নি। + +## সোর্স থেকে বিল্ড + +JDK 21 ও Android SDK ব্যবহার করুন; নিজের SDK path দিয়ে `local.properties` তৈরি করুন। `app/build.gradle.kts`-এ version বদলাবেন না। + +```sh +./gradlew :app:assembleCompactTest :app:testCompactTestUnitTest +``` + +এই পরিবেশে offline build command: + +```sh +JAVA_HOME=/workspace/.jdk/temurin-21 ANDROID_HOME=/workspace/android-sdk ./gradlew --offline :app:assembleCompactTest :app:testCompactTestUnitTest -x :app:lintVitalAnalyzeCompactTest -x :app:lintVitalReportCompactTest -x :app:lintVitalCompactTest +python3 tools/verify-player-correction.py +python3 tools/verify-audio-downloads.py +``` + +`verification/audio-downloads/`-এ বর্তমান বিল্ড ও টেস্ট ফল রয়েছে। আগের ZIP-নির্ভর byte comparison script-এর জন্য আগের compact-toggle source ZIP parent folder-এ রাখতে হয়; APK build বা JVM tests-এর জন্য তা লাগে না। সোর্স ZIP-এ local SDK path, build cache, APK ও signing key নেই। চূড়ান্ত APK ও source ZIP-এর SHA-256 আলাদা manifest-এ আছে। diff --git a/BUILD_NOTES_BN.md b/BUILD_NOTES_BN.md new file mode 100644 index 0000000..b0ba924 --- /dev/null +++ b/BUILD_NOTES_BN.md @@ -0,0 +1,36 @@ +# SparkTube 1.4.1 — টেস্ট বিল্ড + +> এই ZIP-এর সর্বশেষ ছোট APK, লকস্ক্রিন Next ও Audio/Video পিলের পরিবর্তন এবং বিল্ড নির্দেশনা `SIZE_OPTIMIZATION_BN.md`-এ আছে। নিচের নোটগুলো আগের বড় debug টেস্ট বিল্ডের ইতিহাস; `assembleDebug` এখনও সেই ডিবাগ ধরনের বিল্ড তৈরি করে। + +মূল Kotlin Android প্রজেক্টেই পরিবর্তন করা হয়েছে। প্যাকেজ `com.sparktube.app`, `versionName = 1.4.1` ও `versionCode = 10` অপরিবর্তিত। এটি debug-signed টেস্ট APK, প্রকাশনার release APK নয়। আগের original-debug APK-এর সঙ্গে signing certificate একই। + +## পরিবর্তন + +- সার্চ রাখা হয়েছে; পাশের crash-test/bug বাটন সরানো হয়েছে। +- Library-র তালিকায় ডানে/বামে সোয়াইপ করলে পাশের ক্যাটাগরি নির্বাচিত হয়। Downloads-এ অডিও ও ভিডিও উভয় ট্রান্সফার দেখা যায়। +- ডাউনলোডে pause/resume, শতাংশ, প্রতি সেকেন্ডে আপডেট হওয়া Mbps এবং সম্পন্ন ফাইল অন্য অ্যাপে শেয়ার করার কন্ট্রোল আছে। রেঞ্জ ডাউনলোডে পুনঃচেষ্টার সময় লেখা বাইটের পর থেকে ডাউনলোড হয়। +- ভিডিওর প্রকাশ্য ১০-সেকেন্ড rewind/forward বাটন লুকানো হয়েছে। ভিডিওর ফাঁকা ডান/বাম অংশে ডাবল-ট্যাপ করলে ১০ সেকেন্ড সামনে/পিছনে যায়। Play/pause, previous/next, seekbar ও settings রাখা হয়েছে; মাঝের play/pause বাটন গোলাকার। +- ভিডিওর উপরের কোণায় Audio এবং অডিও পেজে Video সুইচ আছে। অনলাইন অডিও থেকে ভিডিওতে গেলে অবস্থান ও অবশিষ্ট কিউ সংরক্ষণের ব্যবস্থা আছে। +- ভিডিওর ডান দিকে উল্লম্ব সোয়াইপে media volume, বামে বর্তমান window-র brightness বদলায়। শতাংশের feedback দেখায়। মাঝখানে নিচে সোয়াইপে আগের মতো minimize হয়। বাস্তব বাটন ও seekbar জেসচার থেকে বাদ রাখা হয়েছে। + +## যাচাই + +`assembleDebug` ও `testDebugUnitTest` সফল। জেসচার গণনার ৭টি JVM টেস্ট পাস: দুই দিকের ১০-সেকেন্ড সিক, শুরু/শেষের সীমা, অজানা duration, overflow, volume ও brightness সীমা। এটি touch dispatch বা আসল playback/download-এর end-to-end পরীক্ষা নয়। সব ১৩০টি XML parse হয়েছে। APK-এর স্বাক্ষর এবং ভেতরের package/version যাচাই করা হয়েছে। + +কোনো Android ফোন বা emulator সংযুক্ত ছিল না; ফোনে install, playback, pause/resume, share এবং gesture-এর ব্যবহারিক পরীক্ষা হয়নি। Android lint চালানোর চেষ্টা হয়েছে, কিন্তু প্রয়োজনীয় `lint-gradle:31.6.1` অফলাইন ক্যাশে না থাকায় সম্পন্ন হয়নি। বিদ্যমান Kotlin/D8 metadata warning আছে; তা সত্ত্বেও APK বিল্ড সফল। + +## সীমাবদ্ধতা + +Pause/resume এই অ্যাপের চলমান প্রসেসের মধ্যে কাজ করে। প্রসেস মেরে ফেলা বা ফোন restart-এর পর আংশিক ডাউনলোড পুনরারম্ভ হয় না; নতুন করে ডাউনলোড প্রয়োজন। Pause চাপার সময় চলমান একটি blocking read শেষ হতে পারে। রেঞ্জ সমর্থন না করা সার্ভারের সংযোগ pause-এর পরে বিচ্ছিন্ন হলে ট্রান্সফার ব্যর্থ হতে পারে। + +AV pair ডাউনলোড আগের মতো পৃথক video ও audio track; শেয়ারের সময় দুটি ফাইল যায়, একত্রিত MP4 নয়। শুধু স্থানীয় অডিও ফাইলে ভিডিও ট্র্যাক না থাকলে Video সুইচ সতর্কবার্তা দেখায়। + +## আবার বিল্ড + +Android Studio-তে ফোল্ডার খুলুন। Android SDK 35, build-tools 34.0.0 ও উপযুক্ত JDK (এই বিল্ডে JDK 21) ব্যবহার করুন। নিজের SDK path দিয়ে `local.properties` তৈরি করুন অথবা `ANDROID_HOME` সেট করুন। + +```sh +./gradlew :app:assembleDebug :app:testDebugUnitTest +``` + +প্রথমবার Gradle/plugin/Android লাইব্রেরি ডাউনলোডের জন্য ইন্টারনেট প্রয়োজন। পরবর্তীতে ক্যাশ সম্পূর্ণ থাকলে `--offline` ব্যবহার করা যায়। টেস্টের JUnit ও Hamcrest jar `app/libs/test/`-এ রাখা আছে; এগুলো production APK dependency নয়। `verification/` ফোল্ডারে বিল্ড লগ ও টেস্ট ফল দেওয়া হয়েছে। স্থানীয় SDK path, build cache ও signing key সোর্স ZIP-এ নেই। diff --git a/LANDSCAPE_BRIGHTNESS_PIP_BN.md b/LANDSCAPE_BRIGHTNESS_PIP_BN.md new file mode 100644 index 0000000..47fd379 --- /dev/null +++ b/LANDSCAPE_BRIGHTNESS_PIP_BN.md @@ -0,0 +1,35 @@ +# SparkTube 1.4.1 — Landscape brightness ও PiP cleanup + +## পরিবর্তন + +Portrait অবস্থায় বাম পাশের vertical swipe দিয়ে brightness পরিবর্তন হবে না; brightness gesture শুধু landscape-এ কাজ করবে। Landscape থেকে portrait-এ ফিরলে অ্যাপের brightness override বাদ দিয়ে সিস্টেমের brightness ব্যবহার হবে। Volume, double-tap seek, horizontal seek এবং মাঝখানে নিচে swipe করে mini player খোলার আচরণ রাখা হয়েছে। + +নতুন ভিডিও/ভিডিও প্লেলিস্ট/অডিও নির্বাচন করলে আগের PiP session-এর floating Activity বন্ধ করা হয়; শুধু player surface সরিয়ে কালো window ফেলে রাখা হয় না। নতুন watch page খুললেও পুরোনো PiP বন্ধ হয়। বন্ধ হতে থাকা Activity-র দেরিতে আসা callback নতুন player surface নিতে বা নতুন PiP-এর state মুছতে পারবে না। একই PiP-এর ভেতর স্বয়ংক্রিয় queue advance-এ PiP বন্ধ করা হয় না। + +VersionName 1.4.1, versionCode 10 এবং পূর্বের compact test APK-এর signing certificate অপরিবর্তিত। Controller layout, seek bar, downloads, audio, sleep timer এবং অন্য app source অপরিবর্তিত রাখা হয়েছে; সুনির্দিষ্ট file-scope যাচাই `tools/verify-landscape-brightness-pip.py`-তে আছে। + +## বিল্ড ও যাচাই + +JDK 21 এবং Android SDK path দিয়ে `local.properties` তৈরি করে: + +```sh +./gradlew --offline --no-daemon :app:testCompactTestUnitTest :app:assembleCompactTest \ + -x lintVitalAnalyzeCompactTest -x lintVitalReportCompactTest -x lintVitalCompactTest +python3 tools/verify-landscape-brightness-pip.py +python3 tools/package-landscape-brightness-pip.py +``` + +প্যাকেজিং স্ক্রিপ্টের baseline APK/source এবং SDK build-tools path একই workspace-এ থাকা প্রয়োজন। APK Gradle-এর existing debug signing config ব্যবহার করে; আগের APK-র সঙ্গে update compatibility রাখতে সেই একই signing keystore প্রয়োজন। ZIP-এ keystore বা secret দেওয়া হয়নি। নতুন কম্পিউটারে প্রথম build-এ dependency download করার জন্য `--offline` বাদ দিতে হতে পারে। + +`verification/landscape-brightness-pip/`-তে build log, unit test XML, source regression ফল, certificate/zipalign যাচাই এবং summary আছে। Lint চালানো হয়নি; baseline-এর offline lint dependency অনুপস্থিত থাকায় lint-vital task বাদ দেওয়া হয়েছে। + +## ফোনে পরীক্ষা বাকি + +Android ফোন বা emulator সংযুক্ত নেই; PiP window বন্ধ হওয়া ও gesture-এর বাস্তব আচরণ এখানে চালিয়ে পরীক্ষা করা হয়নি। ফোনে পরীক্ষা করুন: + +1. Portrait-এ বাম দিকে উপরে/নিচে swipe করলে brightness বা brightness feedback বদলায় না। +2. Landscape-এ একই swipe brightness বদলায়; portrait-এ ফিরলে সিস্টেম brightness ফিরে আসে। +3. ভিডিও A থেকে PiP খুলে অ্যাপে ফিরে ভিডিও B চালালে A-এর floating window বন্ধ হয়, B মূল player-এ চলে। দ্রুত B/C নির্বাচন করেও পরীক্ষা করুন। +4. নতুন ভিডিও playlist বা audio চালালেও পুরোনো PiP থাকে না। +5. PiP থেকে পূর্ণ পর্দায় ফিরে আসা, আবার PiP খোলা, PiP বন্ধ করা এবং স্বয়ংক্রিয় next-video পরীক্ষা করুন। +6. Volume/seek, mini player, downloads, notification, audio এবং sleep timer আগের মতো কাজ করে কি না দেখুন। diff --git a/LANDSCAPE_GESTURES_PIP_BN.md b/LANDSCAPE_GESTURES_PIP_BN.md new file mode 100644 index 0000000..47bd369 --- /dev/null +++ b/LANDSCAPE_GESTURES_PIP_BN.md @@ -0,0 +1,42 @@ +# SparkTube 1.4.1 — Landscape-only brightness ও volume gesture + +## কী বদলেছে + +Portrait-এ বাম পাশের brightness এবং ডান পাশের sound-volume vertical swipe—দুটিই বন্ধ। Landscape-এ দুটি gesture আগের মতো থাকে। Hardware volume button বা system volume control বন্ধ করা হয়নি। Landscape থেকে portrait-এ ফিরলে আগের মতো system brightness ফিরে আসে। + +Horizontal seek, double-tap seek এবং মাঝখানে নিচে swipe করে minimize করার কোড অপরিবর্তিত। সর্বশেষ PiP cleanup fix, audio, downloads, sleep timer, notification, controls এবং অন্য সব app source অপরিবর্তিত। আগের `landscape-brightness-pip` সোর্সের তুলনায় app source-এ শুধু `PlayerActivity.kt`-এর brightness-only guard দুই side gesture-এর আগের যৌথ জায়গায় সরানো হয়েছে। + +VersionName **1.4.1**, versionCode **10**, compactTest configuration এবং আগের test APK-এর signing certificate বজায় রাখা হয়েছে। `LANDSCAPE_BRIGHTNESS_PIP_BN.md` আগের পরিবর্তনের ঐতিহাসিক নোট; portrait volume সম্পর্কে এই নতুন নোট প্রযোজ্য। + +## পুনরায় বিল্ড + +JDK 21 এবং Android SDK ব্যবহার করুন। নিজের SDK path দিয়ে `local.properties` বানিয়ে চালান: + +```sh +./gradlew --offline --no-daemon :app:testCompactTestUnitTest :app:assembleCompactTest \ + -x lintVitalAnalyzeCompactTest -x lintVitalReportCompactTest -x lintVitalCompactTest +``` + +নতুন কম্পিউটারে dependency download করতে `--offline` বাদ দিতে হতে পারে। APK পাওয়া যাবে `app/build/outputs/apk/compactTest/app-compactTest.apk`-এ। আগের APK-এর update-compatible signing পেতে একই বিদ্যমান debug keystore প্রয়োজন; ZIP-এ keystore বা secret নেই। + +সোর্স রিগ্রেশন ও প্যাকেজিং পুনরায় চালাতে: + +```sh +mkdir -p verification/landscape-gestures-pip +python3 tools/verify-landscape-gestures-pip.py > verification/landscape-gestures-pip/source-checks.log 2>&1 +python3 tools/package-landscape-gestures-pip.py +``` + +এই workspace-কেন্দ্রিক যাচাই স্ক্রিপ্টে আগের source ZIP/APK, পুরোনো compact-controls source ZIP এবং Android SDK build-tools path লাগে। প্যাকেজিং স্ক্রিপ্টে বর্তমান সফল build log থাকতে হবে। অন্য পরিবেশে সংশ্লিষ্ট path সামঞ্জস্য করুন। + +`verification/landscape-gestures-pip/`-এ build log, unit-test XML, source-check log, APK version/signature/zipalign evidence এবং summary রয়েছে। পুরোনো offline lint dependency সীমাবদ্ধতার কারণে lint-vital বাদ দেওয়া হয়েছে; lint pass দাবি করা হচ্ছে না। + +## ফোনে পরীক্ষা বাকি + +কোনো Android device বা emulator সংযুক্ত নেই। তাই APK build, JVM unit test ও static checks করা হলেও বাস্তব gesture/PiP runtime পরীক্ষা হয়নি। ফোনে নিশ্চিত করুন: + +1. Portrait-এ বাম ও ডান side swipe করলে brightness/volume বা সংশ্লিষ্ট feedback বদলায় না। Hardware volume button কাজ করে। +2. Landscape-এ বাম brightness এবং ডান volume swipe কাজ করে; portrait-এ ফিরলে system brightness ফিরে আসে। +3. দুই orientation-এ horizontal seek, double-tap seek, center swipe-down ও player controls কাজ করে। +4. ভিডিও A-এর PiP খোলা রেখে নতুন ভিডিও B, playlist বা audio চালালে পুরোনো floating window বন্ধ হয়। দ্রুত একাধিক ভিডিও নির্বাচন করেও পরীক্ষা করুন। +5. PiP expand/close/reopen ও automatic next-video আগের মতো চলে। Downloads, audio, notification ও sleep timer-ও পরীক্ষা করুন। diff --git a/NOTIFICATION_AUDIO_FIX_BN.md b/NOTIFICATION_AUDIO_FIX_BN.md new file mode 100644 index 0000000..cd8cf5f --- /dev/null +++ b/NOTIFICATION_AUDIO_FIX_BN.md @@ -0,0 +1,34 @@ +# SparkTube 1.4.1 — নোটিফিকেশন ও অডিও ফাইল খোলা + +## এই আপডেটে +নোটিফিকেশন থেকে আসা Play/Pause key-down এখন MediaSession callback-এ সরাসরি session player-কে দেওয়া হয় এবং handled হিসেবে ফেরত যায়। এতে ওই ট্যাপ Media3-এর সাধারণ media-key fallback/command-queue পথে যায় না। Media3 1.3.1-এর bytecode-এ কিছু legacy play/pause key-এর জন্য double-tap timeout এবং দ্বিতীয় ট্যাপকে Next হিসেবে নেওয়ার পথ আছে; নোটিফিকেশনের পৃথক ট্যাপে সেই gesture recognizer প্রযোজ্য নয়। এই পরিবর্তন শুধু notification controller-এর toggle key-এ সীমিত; headset, Bluetooth-এর native transport, Previous/Next ও অন্য commands আগের পথে থাকে। নির্দিষ্ট ফোনে আগের ব্যর্থতার একমাত্র কারণ এটি—এমন runtime প্রমাণ নেই। + +বাফারিংয়ে `isPlaying` নয়, `playWhenReady` অনুযায়ী Play/Pause বদলায়। একই ট্যাপ দ্বিতীয়বার default handler-এ যায় না, held-key repeat উপেক্ষা করা হয়। Idle source-এ prepare এবং ended source-এ restart-এর আচরণ রাখা হয়েছে। আগে করা pending-video pause, queue ও player fixes অপরিবর্তিত। + +Library → Downloads-এ সম্পন্ন AUDIO আইটেমে ট্যাপ করলে তার বিদ্যমান নির্দিষ্ট `file://` ফাইলের এক-আইটেম music queue চালু হয় এবং Now Playing খোলে। অনলাইনে আবার resolve করা হয় না। Audio Downloads তালিকার বিদ্যমান নির্দিষ্ট ফাইল বেছে চালানোর আচরণ রাখা হয়েছে। ফাইল মুছে গেলে missing-file বার্তা আসে; ভিডিও/AV downloads আগের আচরণেই থাকে। + +## যাচাই +বিল্ড সফল। 77 JVM unit tests ও 52 source/XML checks পাস; এগুলো Android runtime test নয়। নতুন transport tests-এ প্রথম ট্যাপ, দ্রুত আলাদা ট্যাপ, buffering, key-up/repeat, headset fallback, command availability এবং idle/ended source আছে। Latest fast-home source-এর সঙ্গে app files তুলনায় কেবল PlaybackService.kt ও LibraryFragment.kt বদলেছে; নতুন handler ও তার unit test যোগ হয়েছে। Version **1.4.1 / code 10**, package ও আগের test APK-এর signing certificate এক। Dependencies, resources ও build configuration অপরিবর্তিত। ZIP alignment এবং signing verification পাস। + +কোনো Android ফোন বা emulator সংযুক্ত নেই। Notification tap latency, install/update ও লোকাল অডিও playback-এর device test করা হয়নি; তাৎক্ষণিক audible playback-এর নিশ্চয়তা দেওয়া হচ্ছে না। Network buffering চললে Play চাপার পরে মিডিয়া শুরু হতে সময় লাগতে পারে। Offline lint dependency সীমার কারণে lintVital বাদ ছিল, lint পাস দাবি করা হচ্ছে না। + +## ফোনে সংক্ষিপ্ত পরীক্ষা +১. চলমান অডিও ও ভিডিওতে notification Pause একবার চাপুন; তারপর Play একবার। দ্রুত পরপর পৃথক ট্যাপেও কিউ যেন Next না হয়। +২. Buffering ও নতুন ভিডিও resolving অবস্থায় Pause দিয়ে অপেক্ষা করুন; স্ট্রিম তৈরি হলে নিজে থেকে বাজা উচিত নয়। Headset single/double-tap, Previous/Next ও screen-off controls-ও দেখুন। +৩. Downloads এবং Audio Downloads-এ একাধিক সম্পন্ন অডিওর আলাদা আলাদা আইটেম চাপুন; প্রতিবার ট্যাপ করা লোকাল ফাইলই যেন চালু হয়। Offline অবস্থায়ও দেখুন। Missing file, paused/incomplete download ও video/AV download যাচাই করুন। +৪. PiP, home feed, swipe seek ও downloads-এর আগের আচরণ দেখুন। + +## সোর্স থেকে বিল্ড +JDK 21 ও Android SDK দিয়ে নিজের local.properties তৈরি করুন। + +```sh +./gradlew :app:assembleCompactTest :app:testCompactTestUnitTest +``` + +এখানে ব্যবহৃত command: + +```sh +JAVA_HOME=/workspace/.jdk/temurin-21 ANDROID_HOME=/workspace/android-sdk ./gradlew --offline :app:assembleCompactTest :app:testCompactTestUnitTest --rerun-tasks -x :app:lintVitalAnalyzeCompactTest -x :app:lintVitalReportCompactTest -x :app:lintVitalCompactTest +``` + +ZIP-এ editable source, Gradle wrapper, vendored libraries, unit tests এবং `verification/notification-first-tap/`-এ যাচাইয়ের প্রমাণ আছে। Signing key, APK, machine-local SDK path ও build caches নেই। সোর্স থেকে নিজের build-এর signing key আলাদা হতে পারে। Historical scope comparison-এর জন্য আগের source ZIP লাগে, APK build বা JVM tests-এর জন্য নয়। diff --git a/PIP_FIX_BN.md b/PIP_FIX_BN.md new file mode 100644 index 0000000..d7924be --- /dev/null +++ b/PIP_FIX_BN.md @@ -0,0 +1,35 @@ +# SparkTube — পপআপ/PiP ভিডিও সংশোধন + +## কী ঠিক করা হয়েছে +আগের কোডে `onPause()`-এর সময় PiP flag এখনো false থাকলে ভিডিওর TextureView থেকে player বিচ্ছিন্ন হতো। PiP callback শুধু layout বদলাত; player ফের জোড়া দিত না। ফলে সিস্টেমের পপআপ controls থাকলেও ভিডিওর আউটপুট হারানোর পথ ছিল। এটি কোডভিত্তিক diagnosis; ফোনে সমস্যাটি reproduce করা হয়নি। অ্যাপে আলাদা floating-overlay service নেই: Android PiP একই PlayerActivity/TextureView ব্যবহার করে। + +এখন pause-এর সময় আউটপুট রাখা হয়; activity সত্যিই অদৃশ্য হলে `onStop()`-এ detach হয়। `onStart()`, `onResume()` ও PiP-entry callback আউটপুট নিশ্চিত করে। PiP চলাকালে orientation/configuration callback আর সাধারণ watch-page layout বসাতে পারে না; ভিডিও পুরো ছোট উইন্ডোতে Fit হিসেবে থাকে। পপআপে app-এর controller বন্ধ, সিস্টেমের PiP/media-session controls অক্ষত; বের হলে স্বাভাবিক controller ও portrait/landscape layout ফেরে। Stop-এ PiP flag পরিষ্কার হয়; destroy-এ অতিরিক্ত detach নিরাপত্তা আছে। আগের surface-owner identity guard mini-player-এর নতুন surface ভুল করে বিচ্ছিন্ন হওয়া আটকায়। + +## যা অপরিবর্তিত +versionName **1.4.1**, versionCode **10**, package `com.sparktube.app` এবং আগের test APK-এর signing certificate একই। আগের audio-downloads source ZIP-এর সঙ্গে সব app source, resource, library ও Gradle file তুলনায় কেবল `PlayerActivity.kt` বদলেছে। Player engine, TextureView configuration, gestures ও controls-এর bindings, notification service, compact Audio/Video switch, Library এবং audio/video download UI অপরিবর্তিত। কোনো dependency বা বড় asset যোগ হয়নি। + +APK **10,303,732 bytes**, প্রায় **10.30 MB**; আগের APK থেকে **-100 bytes**। এটি আগের মতো debug-certificate-এ signed compact test build; production release নয়। + +## যাচাই ও সীমা +Build সফল, **46 JVM unit tests** এবং **22 source/XML regression checks** পাস। Package/version, আগের certificate-এর সঙ্গে মিল, APK signature এবং ZIP alignment যাচাই হয়েছে। `verification/pip-fix/`-এ log, diff ও summary আছে। এই source checks Android lifecycle বা ভিডিও rendering-এর runtime test নয়। আগের audio verifier-এর পুরোনো two-file-only historical diff এই নতুন পরিবর্তনের জন্য প্রযোজ্য নয়; তার চারটি feature check চালানো হয়েছে, আর নতুন verifier সর্বশেষ audio-downloads ZIP-এর সঙ্গে সম্পূর্ণ app-file comparison করেছে। + +কোনো physical Android device বা emulator সংযুক্ত নেই। ফোনে install, PiP-তে সত্যিকারের চলমান ছবি, pause/play, ফিরে আসা, resize, notification বা gestures-এর end-to-end test হয়নি। তাই ফোনে সমস্যাটি নিশ্চিতভাবে সেরেছে বলা যাচ্ছে না। Offline cache-এ lint dependency না থাকায় lintVital tasks বাদ দেওয়া হয়েছে; lint পাস দাবি করা হচ্ছে না। + +## ফোনে ছোট পরীক্ষা +ভিডিওতে চলমান ছবি দেখা গেলে Home চাপুন এবং PiP button দিয়েও আলাদাভাবে পরীক্ষা করুন। পপআপে ছবি চলতে থাকা, pause/play, ছোট-বড় করা, পূর্ণ প্লেয়ারে ফেরা, portrait/landscape এবং close করে app খুললে mini-player দেখা যাচাই করুন। এরপর Audio/Video switch, notification controls ও Library/download pages দেখুন। সমস্যা থাকলে ফোনের model/Android version এবং সমস্যার সময়ের SparkTube crash/logcat output প্রয়োজন হবে। + +## সোর্স থেকে বিল্ড +JDK 21 ও Android SDK প্রয়োজন। নিজের SDK path দিয়ে `local.properties` তৈরি করুন; version অপরিবর্তিত রাখুন। সাধারণ build: + +```sh +./gradlew :app:assembleCompactTest :app:testCompactTestUnitTest +python3 tools/verify-pip-fix.py +``` + +এখানে ব্যবহৃত offline build: + +```sh +JAVA_HOME=/workspace/.jdk/temurin-21 ANDROID_HOME=/workspace/android-sdk ./gradlew --offline :app:assembleCompactTest :app:testCompactTestUnitTest -x :app:lintVitalAnalyzeCompactTest -x :app:lintVitalReportCompactTest -x :app:lintVitalCompactTest +``` + +APK build/JVM tests-এর জন্য পুরোনো ZIP লাগে না। Historical byte-comparison verifier-এর জন্য parent directory-তে আগের audio-downloads ও player-fixed source ZIP প্রয়োজন। Source ZIP-এ editable source, wrapper, dependencies-এর vendored JAR, tests ও verification আছে; local SDK path, cache, build output, APK ও signing key নেই। APK ও ZIP-এর SHA-256 আলাদা manifest-এ দেওয়া হয়েছে। diff --git a/PLAYER_CORRECTION_BN.md b/PLAYER_CORRECTION_BN.md new file mode 100644 index 0000000..6d49beb --- /dev/null +++ b/PLAYER_CORRECTION_BN.md @@ -0,0 +1,54 @@ +# SparkTube 1.4.1 — প্লেয়ার সংশোধন + +এই সংশোধনটি আগের `SparkTube-1.4.1-optimized-test.apk`-এর ওপর ভিত্তি করে। সংস্করণ অপরিবর্তিত: `versionName 1.4.1`, `versionCode 10`। নতুন আউটপুটের নাম `SparkTube-1.4.1-player-fixed-test.apk`। এটি debug-key-signed টেস্ট বিল্ড, production release নয়। আগের নোটের চেয়ে বর্তমান প্লেয়ারের আচরণের জন্য এই নোট প্রযোজ্য। + +## কী বদলেছে + +ভিডিও প্লেয়ারে বামে Previous, মাঝখানে বড় Play/Pause এবং ডানে Next। Media3 1.3.1-এর standard controller ছোট উচ্চতার প্লেয়ারকে minimal mode-এ নিয়ে `exo_prev` ও `exo_next` লুকায়; স্ক্রিনশটে শুধু মাঝের Pause থাকার সঙ্গে এটি মিলে যায়। এবার নিজস্ব controller layout ও queue-button ID ব্যবহার করা হয়েছে, যাতে সেই নিয়ম বাটন দুটো লুকাতে না পারে। Native Play/Pause, seekbar, সময় ও settings রাখা হয়েছে। কিউয়ের অবস্থা বদলালে বাটনের enabled state আপডেট হয়। Next সরাসরি পরের queued item চালায়—১০ সেকেন্ড seek নয়। পরের item না থাকলে Next দৃশ্যমান কিন্তু ম্লান ও নিষ্ক্রিয় থাকে। প্রথম item-এ Previous বর্তমান item শুরু থেকে চালানোর আগের আচরণ রাখে। + +দুই ভিউতেই Audio/Video switch এখন দৃশ্যত 80×32dp গাঢ় pill, 19dp আইকন এবং ছোট সবুজ active segment। ট্যাপের জায়গা 92×48dp রাখা হয়েছে। বর্তমান মোড ও বদলানোর দিক accessibility description-এ উল্লেখ আছে। + +কালো ভিডিওর সম্ভাব্য rendering-path সমস্যার জন্য watch-page `TextureView` বদলে native `SurfaceView` করা হয়েছে। একই ExoPlayer-এর mini-player ও watch-player সংযোগে একটিমাত্র active view রাখা হয়েছে; Media3-এর `PlayerView.switchTargetView` দিয়ে নতুন output আগে যুক্ত করে পুরোনো view ছাড়ানো হয়। একই view পুনরায় attach ও পুরোনো Activity callback-এ অপ্রয়োজনীয় output পরিবর্তন এড়ানো হয়েছে। + +**কালো ভিডিওর একমাত্র মূল কারণ নিশ্চিত হয়নি।** ডিভাইসের decoder/stream/rendering লগ বা সরাসরি playback না দেখে screenshot থেকে সেই নিশ্চয়তা দেওয়া যায় না। Surface ও handoff সংশোধন বাস্তব ফোনে যাচাই প্রয়োজন; কেবল build সফল হওয়া ভিডিও দৃশ্যমান হওয়ার প্রমাণ নয়। + +## ডাউনলোডের নতুন হিসাব ও মেনু + +সক্রিয় download row-তে downloaded/total MB, বর্তমান MB/s এবং আনুমানিক বাকি সময় দেখানো হয়। একক decimal megabyte: 1 MB = 1,000,000 bytes; MB/s-কে Mbps হিসেবে ভুল লেবেল দেওয়া হয় না। UI refresh-এর সংখ্যা দিয়ে speed sample বদলায় না; transferred-byte updates এবং monotonic সময় থেকে rate গণনা হয়। Pause/resume-এ sample reset হয়; stall হলে পুরোনো speed শূন্য হয়। ETA বাকি bytes/current rate থেকে ঊর্ধ্বমুখী পূর্ণ সেকেন্ডে হিসাব হয় এবং `~` দিয়ে আনুমানিক বোঝানো হয়—ভবিষ্যতের নেটওয়ার্ক গতি নিশ্চিত করা যায় না। + +Audio+Video জোড়ার উভয় অংশের bytes/total একসঙ্গে ধরা হয়। কোনো অংশের total অজানা থাকলে সম্পূর্ণ size ও ETA অজানা দেখানো হয়, বানানো percentage/সময় নয়। HTTP full-response Content-Length পাওয়া গেলে ব্যবহার হয়; এক-byte range probe-এর Content-Length-কে মোট size ধরা হয় না। Completed row-তে আসল ফাইলের মোট size এবং ডানদিকে 3-dot মেনু থাকে: Play, Share, Delete। Delete আগের confirmation ব্যবহার করে; AV sharing আগের মতো দুইটি আলাদা track file দেয়, নতুন muxing ফিচার যোগ হয়নি। + +## কী অক্ষুণ্ণ রাখা হয়েছে + +Double-tap ±10-second seek এবং volume/brightness gesture কোড আছে। Notification/lock-screen queue-aware Previous/Play-Pause/Next service, download sharing এবং swipeable Library-এর কোড আগের source ZIP-এর সঙ্গে অপরিবর্তিত থাকার পরীক্ষা করা হয়েছে। Download engine ও download-row UI কেবল নতুন অনুমোদিত metrics/menu কাজের জন্য সংশোধিত। বিদ্যমান vendored library JAR অপরিবর্তিত। অ্যাপের R8 minification ও resource shrinking বন্ধ; কোনো feature/dependency/ABI/ভাষা বাদ দেওয়া হয়নি। বাস্তবে সব feature end-to-end চালিয়ে পরীক্ষা হয়েছে—এমন দাবি করা হচ্ছে না। + +## পরীক্ষা ও সীমাবদ্ধতা + +বর্তমান APK 10.30 MB (10,300,268 bytes)। 31টি JVM test এবং ৯টি structural test পাস করেছে; failure/error/skipped শূন্য। APK signature ও zip alignment যাচাই সফল; manifest ও signing certificate আগের optimized APK-এর সঙ্গে একই। + +`verification/player-correction/`-এ বর্তমান build log, structural test output, JVM test XML, manifest, signature ও artifact summary থাকবে। কাঠামোগত পরীক্ষা render/touch/device test নয়। পুরোনো `verification/size-optimization/` আগের বিল্ডের ঐতিহাসিক প্রমাণ, বর্তমান ফল নয়। + +কোনো physical Android device সংযুক্ত নেই; `adb devices`-এ কোনো device নেই। Android emulator-ও এখানে চালু নেই। তাই ফোনে install/update, ছবিসহ playback, fullscreen/PiP, gesture touch, lock-screen rendering ও download end-to-end পরীক্ষা করা যায়নি। + +অফলাইন cache-এ `lint-gradle:31.6.1` অনুপস্থিত থাকায় আগের মতো lintVital task বাদ দিয়ে বিল্ড করা হয়েছে; lint সফল হয়েছে বলা হচ্ছে না। পুরোনো Kotlin/D8 metadata ও deprecation warning থাকতে পারে। + +## আবার বিল্ড + +JDK 21 এবং Android SDK 35 ব্যবহার করুন। নিজের SDK path দিয়ে `local.properties` তৈরি করুন অথবা `ANDROID_HOME` সেট করুন। স্বাভাবিকভাবে: + +```sh +./gradlew :app:assembleCompactTest :app:testCompactTestUnitTest +python3 tools/verify-player-correction.py +``` + +এই পরিবেশে ব্যবহৃত অফলাইন কমান্ড: + +```sh +./gradlew --offline --no-daemon \ + :app:assembleCompactTest :app:testCompactTestUnitTest \ + -x :app:lintVitalAnalyzeCompactTest \ + -x :app:lintVitalReportCompactTest \ + -x :app:lintVitalCompactTest +``` + +APK: `app/build/outputs/apk/compactTest/app-compactTest.apk`। Editable source ZIP-এ build/cache, local SDK path বা signing key নেই। ভিন্ন কম্পিউটারে debug key আলাদা হলে তৈরি APK-এর signature-ও আলাদা হবে। বিতরণের APK ও source ZIP-এর checksum আলাদা SHA-256 manifest-এ দেওয়া হয়েছে। diff --git a/PORTRAIT_SEEK_BN.md b/PORTRAIT_SEEK_BN.md new file mode 100644 index 0000000..bd9c2cb --- /dev/null +++ b/PORTRAIT_SEEK_BN.md @@ -0,0 +1,28 @@ +# পোর্ট্রেট seek: intentional gesture রাখুন, accidental touch আটকান + +পোর্ট্রেটে ভিডিওর ওপর intentional horizontal swipe seek এবং বাম/ডান অর্ধে double-tap করে −১০/+১০ সেকেন্ড seek ফিরিয়ে দেওয়া হয়েছে। Single tap শুধু controls দেখায়/লুকায়। Surface swipe আগের মতো ১৪dp movement ও horizontal dominance শনাক্ত হওয়ার পরে preview দেখায়; আঙুল ছাড়লে seek হয়। শুরু ও শেষের orientation আলাদা হলে surface seek commit হয় না। + +## নিচের seek bar + +`DeliberateSeekTimeBar` native Media3 time bar-এর subclass। শুধু portrait touch-এ প্রথম DOWN আটকে রাখে। অন্তত ১৪dp (বা system touch slop, যেটি বেশি) এবং স্পষ্ট horizontal movement হলে buffered DOWN ও MOVE native time bar-এ পাঠায়। তাই tap, ছোট jitter, vertical বা ambiguous diagonal drag thumb সরায় না বা playback seek করে না। Meaningful non-horizontal drag একবার reject হলে একই touch sequence পরে horizontal হয়ে scrub করতে পারে না। Multi-touch, CANCEL, rotation ও detach-এ pending/active portrait scrub বাতিল হয়। Native scrub listeners, preview ও release-to-seek behavior-ই ব্যবহার করা হয়; আলাদা playback seek loop নয়। Keyboard ও accessibility actions native Media3-তেই থাকে। + +Touch ownership শুরুতে নির্ধারিত হয়। ভিডিওর surface থেকে bar-এ আঙুল সরানো native timebar drag শুরু করে না; সেটি intentional surface gesture হিসেবেই থাকে। আসল buttons তাদের আগের touch flow পায়। Landscape-এ timebar সরাসরি native `onTouchEvent` ব্যবহার করে; একই native style, dimensions এবং surface seek/brightness/volume আচরণ রাখা হয়েছে। + +## ভিডিও কার্ডের Download + +ভিডিও কার্ডের three-dot menu-তে Download যোগ করা হয়েছে। বিদ্যমান download flow, progress এবং library UI ব্যবহার করা হয়; অন্য menu actions রাখা হয়েছে। + +## Build ও যাচাই + +versionName **1.4.1**, versionCode **10**। Build/signing configuration এবং dependency অপরিবর্তিত। Source ZIP-এ keystore/password নেই; অন্য পরিবেশে একই signing certificate পেতে আগের signing key প্রয়োজন। SDK path নিজের `local.properties`-এ দিন। + +এই পরিবেশে: + +```bash +JAVA_HOME=/workspace/.jdk/temurin-21 ANDROID_HOME=/workspace/android-sdk ./gradlew --offline :app:assembleCompactTest :app:testCompactTestUnitTest -x :app:lintVitalAnalyzeCompactTest -x :app:lintVitalReportCompactTest -x :app:lintVitalCompactTest +python3 tools/package-gestures-download.py +``` + +Build/test/signature/version/archive verification-এর ফল `verification/gestures-download/verification-summary.json`-এ থাকে। Local JVM tests pure gesture math/gate/policy পরীক্ষা করে; native Android touch integration পরীক্ষা করে না। Android ফোন বা emulator সংযুক্ত নেই। Runtime touch, visual overlap, download network flow, rotation ও landscape regression বাস্তব ডিভাইসে পরীক্ষা করা হয়নি। Lint এই build-এ বাদ দেওয়া হয়েছে। + +ফোনে পরীক্ষা: portrait surface swipe, দুই পাশে double-tap, single tap; bar tap/jitter/vertical/diagonal drag বনাম deliberate horizontal drag; surface থেকে bar-এ drag; CANCEL/multi-touch/rotation; landscape controls/seek/volume/brightness; এবং card menu Download থেকে progress ও library flow। diff --git a/RENDER_CONTROLS_BN.md b/RENDER_CONTROLS_BN.md new file mode 100644 index 0000000..67dcadc --- /dev/null +++ b/RENDER_CONTROLS_BN.md @@ -0,0 +1,37 @@ +# SparkTube 1.4.1 — ভিডিও রেন্ডারিং ও কন্ট্রোল সংশোধন + +এই নোটটি আগের PLAYER_CORRECTION_BN.md-এর পরবর্তী সংশোধনের বিবরণ। versionName **1.4.1**, versionCode **10** অপরিবর্তিত। এটি আগের পরীক্ষামূলক APK-এর একই debug certificate দিয়ে সই করা compact test build; production/release signing দাবি করা হচ্ছে না। + +## কালো ভিডিওর জন্য পাওয়া সরাসরি কোডের ত্রুটি + +আগের QueueForwardingPlayer-এ `object : Player.Listener by listener` ব্যবহার করা হয়েছিল। এই প্রজেক্টের Kotlin compiler Java interface-এর default callback-গুলোকে ওই delegation দিয়ে ফরওয়ার্ড করেনি। কম্পাইলড bridge class-এ শুধু `onAvailableCommandsChanged` ও `onEvents` ছিল; `onRenderedFirstFrame`, `onVideoSizeChanged`, `onTracksChanged` ছিল না। + +Media3 PlayerView প্রথম ফ্রেমের callback পেলে তার কালো shutter সরায়। আগের bridge callback গিলে ফেললে, ডিকোডার ভিডিও তৈরি করলেও shutter কালো থেকে যেতে পারে। তাই কেবল SurfaceView handoff বদলানো যথেষ্ট ছিল না। নতুন JVM regression test পুরোনো delegation-এ first-frame callback হারানো এবং নতুন bridge-এ পৌঁছানো পুনরুৎপাদন করে। + +সংশোধনে PlayerView সরাসরি raw ExoPlayer-এ যুক্ত হয়েছে; কিউ-সচেতন media session আলাদা facade ব্যবহার করে। সেই facade-এর জন্য PlayerListenerBridge.java Media3 1.3.1-এর ৩৭টি callback স্পষ্টভাবে ফরওয়ার্ড করে। API-coverage test প্রতিটি callback যাচাই করে। + +এটি কোড/bytecode-এ নিশ্চিত ত্রুটি এবং সংশোধন; মালিকের ফোনে কালো স্ক্রিন সেরে গেছে—এমন দাবি নয়। + +## কন্ট্রোল ও রেন্ডারিং + +Previous / বড় Play-Pause / Next এখন activity_player.xml-এর সরাসরি sibling overlay: 52dp / 76dp / 52dp। Media3-এর center/minimal-mode container বা optional findViewById-এর ওপর এই বাটনগুলো নির্ভর করে না। একই সময়ে দুটো Play/Pause দেখানো এড়াতে Media3 template থেকে center transport সরানো হয়েছে; seekbar, সময় ও settings রাখা হয়েছে। Next পরের queued item চালায়; দশ সেকেন্ড seek করে না। প্রথম আইটেমে Previous বর্তমান ভিডিও শুরু থেকে চালায়। + +Main ও mini player-এ TextureView ব্যবহৃত হয়েছে; output owner ও stale detach guard রাখা হয়েছে। Decoder fallback চালু। শুধু READY/isPlaying-কে ভিডিও সফল বলা হয় না: দৃশ্যমান, READY, play-requested অবস্থায় বাস্তব first-frame না এলে একবার output rebind, তারপর progressive source fallback, শেষে স্পষ্ট Retry error। ব্যাকগ্রাউন্ডে/পজ অবস্থায় এই recovery হয় না। source rebuild position ও play/pause intent ধরে রাখে। Retry কিউ মুছে ফেলে না এবং local download-কে online URL হিসেবে resolve করে না। + +## যেসব ফিচার রাখা হয়েছে + +Compact dark/green Audio–Video switch, বাম/ডানে double-tap-এ ১০ সেকেন্ড seek, ডানে vertical volume ও বামে brightness gesture রাখা হয়েছে; standalone দশ সেকেন্ডের বাটন নেই। Lock-screen/notification-এর Previous–Play/Pause–Next queue facade-এ আছে। Swipeable Library, download-এর downloaded/total MB, MB/s, আনুমানিক ETA এবং তিন-ডট Play/Share/Delete-এর source আগের build-এর সঙ্গে byte-for-byte তুলনা করা হয়েছে। Production dependency, সংস্করণ, resource/feature shrinking policy বদলানো হয়নি। + +## পরীক্ষা ও পুনরায় বিল্ড + +JVM test results, structural-test log, compiled APK XML, signature, zipalign এবং version metadata `verification/render-controls/`-এ আছে। এগুলো ফোনে চালানো rendering/touch/instrumentation test নয়। ADB-তে কোনো Android device যুক্ত নেই; emulator-এও চালানো হয়নি। তাই আসল ফোনে video frames, rotation, mini-player, PiP, gesture এবং lock-screen controls যাচাই এখনও প্রয়োজন। Lint-এর অবস্থা verification-summary.json-এ আলাদা দেওয়া আছে। + +JDK 17 ও Android SDK 35 দিয়ে, SDK location স্থানীয়ভাবে সেট করে: + +```sh +./gradlew :app:assembleCompactTest :app:testCompactTestUnitTest \ + -x lintVitalAnalyzeCompactTest -x lintVitalReportCompactTest -x lintVitalCompactTest +python3 tools/verify-player-correction.py +``` + +পুরোনো source ZIP পাশে না থাকলে byte-for-byte baseline comparison test skip হবে; অন্য পরীক্ষা চলবে। সোর্স ZIP-এ local.properties, build cache বা signing keystore নেই। একই versionCode রাখা হয়েছে, তাই পরীক্ষা করার সময় ফোনে সঠিক নতুন APK install হয়েছে কি না নিশ্চিত করা দরকার। স্বাক্ষর না মেললে এই test APK দিয়ে অন্য certificate-এর app-এর ওপর update হবে না; ডেটা হারানোর ঝুঁকি থাকায় যাচাই ছাড়া uninstall করা উচিত নয়। diff --git a/RESPONSIVE_CONTROLS_BN.md b/RESPONSIVE_CONTROLS_BN.md new file mode 100644 index 0000000..16d1b7e --- /dev/null +++ b/RESPONSIVE_CONTROLS_BN.md @@ -0,0 +1,41 @@ +# SparkTube — দ্রুত হোম ফিড, মিডিয়া কন্ট্রোল ও সোয়াইপ সিক + +## নতুন আচরণ +অ্যাপ খোলার সময় আগের সফল হোম ফিডের সীমিত, দেশভিত্তিক cache থেকে ভিডিও কার্ড আগে দেখানো হয়; নতুন ফিড পেছনে refresh হয়। Cache-এ শুধু ভিডিওর তালিকার তথ্য ও thumbnail URL থাকে, playable stream URL নয়। Fresh install বা cache না থাকলে তিনটি feed source একসঙ্গে লোড হয় এবং প্রথম সফল source-এর ফল বাকি source ও পূর্ণ recommendation ranking শেষ হওয়ার আগেই দেখানো হয়। Refresh ব্যর্থ হলে ব্যবহারযোগ্য পুরোনো তালিকা মুছে ফেলা হয় না। প্রথমবারের network request বা uncached thumbnail-কে offline/তাৎক্ষণিক বলে দাবি করা হচ্ছে না। + +নোটিফিকেশনের Previous / Play-Pause / Next রাখা হয়েছে। বাফারিং চলাকালে play/pause এখন `isPlaying` নয়, ব্যবহারকারীর `playWhenReady` intent ধরে কাজ করে। নতুন ভিডিওতে Next/Previous চাপলে আগের ভিডিওর অডিও থামে এবং session-এ নতুন শিরোনাম ও সত্যিকারের loading/buffering অবস্থা স্ট্রিম URL পাওয়ার আগেই পাঠানো হয়। লোডিংয়ের মধ্যে Pause দিলে নতুন source তৈরি হওয়ার পর সেটি নিজে থেকে আর বাজবে না। দ্রুত পরপর Next/Previous চাপলে বাতিল পুরোনো কাজ নতুন নির্বাচনের state মুছতে পারে না। প্রকৃত ভিডিও/অডিও শুরু হতে নেটওয়ার্ক সময় লাগতে পারে; READY বা playing-এর মিথ্যা অবস্থা দেখানো হয় না। + +ভিডিও পেজের সক্রিয় “Downloading · …%” বাটনে চাপলে Library → Downloads খুলবে এবং বিদ্যমান live progress refresh চলবে। ডাউনলোড না চললে আগের quality/audio-video download sheet এবং সম্পূর্ণ ডাউনলোডের delete confirmation থাকে। + +ডানে অনুভূমিক সোয়াইপে ধীরে সামনে, বামে পিছনে seek preview হয়; আঙুল ছাড়লে সেই অবস্থানে যাওয়া হয়। ছোট নড়াচড়া ও দিক নির্বাচন আলাদা করা হয়েছে, যাতে ওপর-নিচের brightness/volume gesture-এর সঙ্গে না মেশে। আগের double-tap ±১০ সেকেন্ডও থাকে। Cancel হলে seek হয় না। + +## যা সংরক্ষিত +সংস্করণ **1.4.1**, versionCode **10**, package `com.sparktube.app` এবং আগের test APK-এর signing certificate অপরিবর্তিত। Compact test build; production release নয়। PiP lifecycle/rendering fix, audio/video downloads, কিউ transport, বিদ্যমান controls ও resources রাখা হয়েছে। নতুন dependency বা বড় asset নেই। APK **10,316,436 bytes** (প্রায় **10.32 MB**); আগের PiP test APK থেকে **+12,704 bytes**। + +## যাচাই ও সীমা +Build সফল; **66 JVM unit tests** এবং **48 source/XML regression checks** পাস। Source checks Android runtime test নয়। সর্বশেষ PiP source ZIP-এর সঙ্গে app files তুলনা করে শুধু অনুমোদিত playback, navigation ও gesture files বদলানোর যাচাই আছে। পুরোনো আপডেটগুলোর historical “অন্য কোনো ফাইল বদলাবে না” assertion বর্তমান scope-এর জন্য বাদ দিয়ে নতুন latest-source scope audit চালানো হয়েছে; feature checks রাখা হয়েছে। Signature/certificate match, version/package এবং ZIP alignment যাচাই হয়েছে। + +কোনো physical Android device বা emulator সংযুক্ত নেই; ফোনে install, notification latency, swipe feel, PiP rendering বা download navigation-এর end-to-end পরীক্ষা করা হয়নি। তাই ফোনে নিশ্চিত তাৎক্ষণিক প্রতিক্রিয়া দাবি করা হচ্ছে না। Offline lint dependency না থাকায় lintVital tasks বাদ; lint পাস দাবি নেই। Logs, diffs, test XML ও summary `verification/notification-response/`-এ আছে। + +## ফোনে সংক্ষিপ্ত পরীক্ষা +০. একবার Home লোড করে অ্যাপ সম্পূর্ণ বন্ধ করে ফের খুলুন; আগের কার্ড আগে দেখা এবং পেছনে refresh পরীক্ষা করুন। Slow/offline network, fresh install, country change ও pull-to-refresh-ও দেখুন; অন্য দেশের cache যেন না আসে। +১. চলমান ও buffering ভিডিও/গানে notification Play/Pause দ্রুত চাপুন; পজ দিলে নেটওয়ার্ক ফেরার পর নিজে থেকে বাজে কি না দেখুন। +২. ভিডিও কিউতে Next/Previous দ্রুত চাপুন; নতুন শিরোনাম/loading, সঠিক শেষ নির্বাচন এবং আগের অডিও বন্ধ হওয়া দেখুন। কিউয়ের শুরু/শেষও পরীক্ষা করুন। +৩. “Downloading · …%” চাপুন; Downloads-এ বর্তমান অগ্রগতি এবং pause/resume/cancel দেখুন। Idle download button-এর sheet ও completed download delete অপরিবর্তিত আছে কি না দেখুন। +৪. ডানে/বামে ধীরে সোয়াইপ, seek preview/release, শুরু/শেষ সীমা, double-tap ±১০ সেকেন্ড, brightness/volume এবং portrait/landscape পরীক্ষা করুন। +৫. Home/PiP, full-player-এ ফেরা এবং audio/video download পরীক্ষা করুন। + +## সোর্স থেকে বিল্ড +JDK 21 ও Android SDK দিয়ে নিজের `local.properties` বানিয়ে চালান: + +```sh +./gradlew :app:assembleCompactTest :app:testCompactTestUnitTest +``` + +এখানে ব্যবহৃত offline command: + +```sh +JAVA_HOME=/workspace/.jdk/temurin-21 ANDROID_HOME=/workspace/android-sdk ./gradlew --offline :app:assembleCompactTest :app:testCompactTestUnitTest -x :app:lintVitalAnalyzeCompactTest -x :app:lintVitalReportCompactTest -x :app:lintVitalCompactTest +``` + +ZIP-এ editable source, Gradle wrapper, vendored libraries, unit tests ও verification আছে; build output/cache, local SDK path, APK ও signing key নেই। APK ও ZIP-এর SHA-256 আলাদা manifest-এ আছে। ঐতিহাসিক diff verifier চালাতে আগের source ZIP parent folder-এ লাগে; APK build বা JVM tests-এর জন্য লাগে না। diff --git a/SIZE_OPTIMIZATION_BN.md b/SIZE_OPTIMIZATION_BN.md new file mode 100644 index 0000000..8a59275 --- /dev/null +++ b/SIZE_OPTIMIZATION_BN.md @@ -0,0 +1,58 @@ +# SparkTube 1.4.1 — ছোট টেস্ট APK + +এই বিল্ডে অ্যাপের ভার্সন বাড়ানো হয়নি: `versionName 1.4.1`, `versionCode 10`, প্যাকেজ `com.sparktube.app`। আগের টেস্ট APK-এর একই signing certificate ব্যবহার করা হয়েছে। + +## সাইজ + +| ফাইল | প্রকৃত সাইজ | +| --- | ---: | +| আগের `SparkTube-1.4.1-test-debug.apk` | 14,995,725 bytes — 15.00 MB | +| নতুন `SparkTube-1.4.1-optimized-test.apk` | 10,287,944 bytes — 10.29 MB | +| কমেছে | 4,707,781 bytes — 31.39% | + +এখানে MB = 1,000,000 bytes; এগুলো ডাউনলোডযোগ্য APK-এর সাইজ, ফোনে ইনস্টল হওয়ার পরের স্টোরেজ নয়। + +## নতুন কন্ট্রোল + +লকস্ক্রিন/মিডিয়া নোটিফিকেশনের দৃশ্যমান হার্ট অ্যাকশন সরিয়ে native Previous, Play/Pause ও Next ব্যবহার করা হয়েছে। MediaSession এখন PlayerView-এর মতো একই স্থিতিশীল, অ্যাপ-কিউ-সচেতন প্লেয়ার ব্যবহার করে। Next বর্তমান গানের ১০ সেকেন্ড এগোয় না; পরের queued গান/ভিডিও চালায়। ভিডিওতে অ্যাপের কিউ এবং অডিওতে ExoPlayer-এর প্রকৃত playlist অনুযায়ী কমান্ডের অবস্থা আপডেট হয়। পরের আইটেম না থাকলে Next নিষ্ক্রিয় থাকে। Previous আগের আইটেমে যায়; প্রথম ভিডিওতে বর্তমান ভিডিও শুরু থেকে চালানোর আগের আচরণ থাকে। অ্যাপের ভেতরের Favorite বাটন ও Favorites তালিকা অক্ষুণ্ণ। + +ভিডিও কন্ট্রোলে দুই পাশে পৃথক গোলাকার Previous/Next এবং মাঝখানে বড় Play/Pause আছে। দৃশ্যমান ১০-সেকেন্ড rewind/forward বাটন নেই; ফাঁকা ভিডিও অংশে ডাবল-ট্যাপ সিক আগের মতো থাকে। + +ভিডিও ও অডিও দুই ভিউয়ের ওপরের Audio/Video সুইচ একই ছোট গাঢ় rounded pill: সক্রিয় মোড সবুজ বৃত্তে, অন্য মোড পাশাপাশি ধূসর আইকনে। আগের switch action, position/queue সংরক্ষণ ও gesture exclusion রাখা হয়েছে। আগের volume/brightness gestures, swipeable Library এবং download pause/resume, progress, speed ও sharing কোড বাদ দেওয়া হয়নি। আগের download সীমাবদ্ধতাগুলো `BUILD_NOTES_BN.md`-এ আছে। + +## নিরাপদে কীভাবে ছোট করা হয়েছে + +আলাদা `compactTest` build type যোগ করা হয়েছে। সাধারণ `debug` ও `release` build type-এর আচরণ বদলানো হয়নি। `compactTest`-এ non-debug D8/L8 compilation ও Android resource packaging optimization ব্যবহার হয়; অ্যাপের R8 minification/obfuscation এবং resource shrinking বন্ধ। কোনো feature, dependency, ABI বা ভাষার তালিকা কেটে ছোট করা হয়নি। সব 6,070টি আগের Android resource name আছে; নতুন ছোট XML drawable তিনটি যোগ হয়েছে। AAPT2 কিছু resource file path ছোট/একত্র করেছে—এটি resource name বা ফিচার মুছে ফেলা নয়। Toolchain-এর স্বাভাবিক L8 প্রক্রিয়া অব্যবহৃত desugared runtime helper বাদ দিতে এবং D8 synthetic class বদলাতে পারে। মূল third-party library-র সাধারণ ক্লাস বাদ যায়নি; vendored JAR অপরিবর্তিত। + +এই APK `android:debuggable=false`, অর্থাৎ debugger attach করার জন্য নয়। কিন্তু `BuildConfig.DEBUG=true` রাখা হয়েছে, যাতে আগের টেস্ট বিল্ডের update-check ও crash-log আচরণ একই থাকে। এটি এখনও debug-key-signed টেস্ট APK; প্রকাশনার production signing key নয়। + +## যাচাই + +- `assembleCompactTest` ও `testCompactTestUnitTest` সফল। ১৫টি JVM টেস্ট পাস: আগের gesture গণনার ৭টি এবং queue boundary-এর নতুন ৮টি; failures/errors/skipped শূন্য। +- ১৩৩টি XML parse হয়েছে। APK signature এবং zip alignment যাচাই সফল। APK-এর ভেতরের version, package, permissions ও manifest component list যাচাই হয়েছে; permissions/components অপরিবর্তিত। +- Certificate SHA-256: `40f61a2530f18ff692b47b627fa45eae3fd66ff95c076a4b461086128a5db766` — আগের টেস্ট APK-এর সঙ্গে একই। +- APK SHA-256: `9c4d5bec11dc003237859a7ee10cb1378fd9bb8bb2f778e66a15133d2b7671f1`। + +কোনো Android ফোন/emulator যুক্ত ছিল না। তাই ফোনে install/update, বাস্তব লকস্ক্রিনের button rendering, native MediaController interaction, touch dispatch, playback ও download-এর end-to-end পরীক্ষা হয়নি। Queue-policy টেস্ট ও static/compile checks-কে সেই পরীক্ষার বিকল্প বলা হচ্ছে না। `lint-gradle:31.6.1` অফলাইন ক্যাশে অনুপস্থিত থাকায় lint সম্পন্ন হয়নি; সফল অফলাইন build-এ তিনটি lintVital task স্পষ্টভাবে বাদ দেওয়া হয়েছে। বিদ্যমান Kotlin/D8 metadata warning-ও রয়েছে; toolchain বদলানো হয়নি। + +## আবার বিল্ড + +JDK 21, Android SDK 35 ও build-tools 34.0.0 ব্যবহার করা হয়েছে। নিজের SDK path দিয়ে `local.properties` তৈরি করুন অথবা `ANDROID_HOME` সেট করুন। স্বাভাবিক অনলাইন বিল্ড: + +```sh +./gradlew :app:assembleCompactTest :app:testCompactTestUnitTest +``` + +এই পরিবেশে ব্যবহৃত অফলাইন কমান্ড, lint-এর উল্লিখিত সীমাবদ্ধতাসহ: + +```sh +./gradlew --offline --no-daemon \ + :app:assembleCompactTest :app:testCompactTestUnitTest \ + -x :app:lintVitalAnalyzeCompactTest \ + -x :app:lintVitalReportCompactTest \ + -x :app:lintVitalCompactTest +``` + +আউটপুট: `app/build/outputs/apk/compactTest/app-compactTest.apk`। বিতরণের জন্য এর নাম `SparkTube-1.4.1-optimized-test.apk` করা হয়েছে। একই কম্পিউটারের বিদ্যমান debug key পুনঃব্যবহার করা হয়েছে; ZIP-এ signing key নেই। অন্য কম্পিউটারে আলাদা debug key থাকলে সই একই হবে না। আগের অ্যাপের ওপর update করতে signature মিলতে হবে; এই বিতরণকৃত APK-এর ক্ষেত্রে তা যাচাই হয়েছে। + +`tools/apk-size-audit.py` দিয়ে দুটি APK-এর ZIP payload এবং DEX class তুলনা করা যায়। `verification/size-optimization/`-এ সফল বিল্ডের `build-controls-final.log`, দুই test suite-এর XML, APK comparison, source hash baseline, resource dump, manifest এবং signature verification আছে। আগের কয়েকটি অসম্পূর্ণ build attempt-এর log-ও রাখা হয়েছে; চূড়ান্ত ফল `verification-summary.json`-এ। Editable source ZIP-এ project source, Gradle wrapper, libraries, tests ও verification আছে; local SDK path, build/cache directory ও signing key নেই। APK ও ZIP-এর SHA-256 আলাদা manifest-এ দেওয়া হয়েছে। diff --git a/SLEEP_TIMER_BN.md b/SLEEP_TIMER_BN.md new file mode 100644 index 0000000..9bc9e62 --- /dev/null +++ b/SLEEP_TIMER_BN.md @@ -0,0 +1,35 @@ +# SparkTube 1.4.1 — Sleep timer ও দ্রুত অডিও সুইচ + +অডিও প্লেয়ারে সবুজ outlined Timer বাটন আছে। ভিডিও পেজের horizontal action row-তে Background play বাটনের জায়গায় Timer রাখা হয়েছে। দুই বাটন একই Sleep timer selector খোলে: Off, 10, 15, 20, 30, 45 minutes, 1 hour এবং End of video। নির্বাচিত অপশনে চেক চিহ্ন থাকে। ছোট স্ক্রিনে তালিকা স্ক্রল করা যায়। + +টাইমার সেট করলে বাটনে মিনিট:সেকেন্ড কাউন্টডাউন দেখায়। ভিডিওতে প্রতি সেকেন্ডে এবং অডিওতে অর্ধ সেকেন্ড অন্তর লেবেল refresh হয়। একটাই app-wide monotonic deadline ব্যবহৃত হয়: pause, seek, playback speed, পেজ বদল বা অডিও/ভিডিও সুইচে নির্ধারিত সময় নতুন করে শুরু হয় না। Off বাতিল করে, অন্য সময় বাছলে নতুন countdown শুরু হয়। + +সময় শেষ হলে shared player বন্ধ হয়; queue, pending stream resolution ও recovery jobs বাতিল হয় যাতে পরে নিজে থেকে আবার চালু না হয়। খোলা player page বন্ধ হয়। এটি অ্যাপের process জোর করে kill করে না এবং downloads বন্ধ করে না। End of video বর্তমান media শেষ হলে থামায়, next item autoplay করার আগে; নিজে অন্য media বাছলে শুধু End of video সেটিং বাতিল হয়। নির্দিষ্ট মিনিটের countdown queue বদলালেও থাকে। + +উপরের ভিডিও→অডিও সুইচে একই ExoPlayer ও source রাখা হয়। সোর্স reload/prepare/seek করা হয় না, তাই পেজ বদলের জন্য নেটওয়ার্কের অপেক্ষা নেই। অডিও পেজ থেকে একই ভিডিওতে ফিরলেও retained source পুনরায় ব্যবহার হয়। প্রকৃত music-only stream থেকে প্রথমবার ভিডিওতে যাওয়া বা পরে নতুন audio-only source লোড হলে আগের মতো resolving প্রয়োজন হতে পারে। + +## যাচাই + +`verification/sleep-timer/`-এ Gradle build log, JVM tests, source/XML regression checks, APK metadata, signing certificate comparison, zip alignment ও verification summary আছে। Package `com.sparktube.app`, versionName `1.4.1`, versionCode `10`, dependencies, manifest ও compact signing configuration অপরিবর্তিত। আগের video-collapse test APK-এর certificate-এর সঙ্গে মিল পরীক্ষা করা হয়। + +কোনো Android ফোন বা emulator সংযুক্ত নেই। ইনস্টল, বাস্তব ট্যাপ/কাউন্টডাউন, 10–60 মিনিটের background run, screen-off/Doze/OEM battery restriction, PiP, transition speed ও নিরবচ্ছিন্ন playback ডিভাইসে পরীক্ষা করা হয়নি। Listener-based wake alarm ও shared service ticker আছে; Android/OEM scheduling-এর বিলম্বমুক্ত কার্যকারিতা বাস্তব ফোনে যাচাই বাকি। Offline lint dependencies সীমাবদ্ধতার কারণে lintVital tasks বাদ দেওয়া হয়েছে; lint pass দাবি করা হচ্ছে না। + +## ফোনে সংক্ষিপ্ত পরীক্ষা + +1. অডিওতে 10 minutes দিন: 10:00 থেকে কমবে। ভিডিওতে switch করে একই বাকি সময় দেখুন। +2. ভিডিওর Timer থেকে সময় বদলান, Off দিন ও শিট পুনরায় খুলে selected চিহ্ন দেখুন। +3. pause/seek/speed পরিবর্তন ও স্ক্রিন বন্ধ রেখেও নির্বাচিত wall-clock সময় শেষে playback বন্ধ হওয়া দেখুন। +4. দুই গান/ভিডিওর queue-তে End of video দিন: পরেরটি যেন নিজে থেকে না চলে। +5. ভিডিও চলার সময় উপরের audio switch চাপুন: পেজ দ্রুত খুলবে, position/প্লেব্যাক অবস্থা থাকবে; ফিরে আসাও দেখুন। +6. Notification controls, downloads, PiP, collapse, quality selection ও queue navigation-এর পূর্বের আচরণ পরীক্ষা করুন। + +## সোর্স থেকে বিল্ড + +JDK 21 ও Android SDK দিয়ে নিজের `local.properties` তৈরি করুন: + +```sh +./gradlew :app:assembleCompactTest :app:testCompactTestUnitTest +python3 tools/verify-current-update.py +``` + +এই পরিবেশে Gradle `--offline -x :app:lintVitalAnalyzeCompactTest -x :app:lintVitalReportCompactTest -x :app:lintVitalCompactTest` দিয়ে চালানো হয়েছে। ZIP-এ editable source, wrapper, vendored libraries ও tests রয়েছে; SDK path, build caches, APK ও signing key নেই। নিজের signing key দিয়ে rebuild করলে certificate আলাদা হবে। ঐতিহাসিক source-scope audit-এ আগের source ZIP লাগে; APK/JVM build-এ লাগে না। diff --git a/UPDATE_1.4.2_BN.md b/UPDATE_1.4.2_BN.md new file mode 100644 index 0000000..3ca35ee --- /dev/null +++ b/UPDATE_1.4.2_BN.md @@ -0,0 +1,21 @@ +# SparkTube 1.4.2 — নতুন আপডেট + +এই আপডেটটি সম্পন্ন হয়েছে এবং এই repo-তে পুশ করা হয়েছে। + +## নতুন আপডেটে যা যা পরিবর্তন করা হয়েছে + +1. **Home feed দ্রুত** — আগের video cards cache থেকে আগে আসে; নতুন feed পেছনে refresh হয়। প্রথমবারের network load অবশ্য connection-এর ওপর নির্ভর করে। + +2. **Video cards** — title-এর পাশে ৩-dot menu-তে Play, Favorite, Share ও Download। Playlist/Library item হলে সরানোর option-ও থাকে। Download চাপলে quality picker ও আগের progress/Downloads flow খোলে। + +3. **Player controls** — Previous, বড় Play/Pause, Next; কালো গোল background ছোট, controls-এর সঙ্গে top arrow hide/show হয়। Portrait seek bar video-র নিচে cleanভাবে থাকে; landscape layout আগের মতো। + +4. **Seek gestures** — নিচের bar-এর visible track/thumb থেকে drag করলে seek হয়; accidental touch/nearby area থেকে নয়। Intentional horizontal swipe seek ও double-tap ±১০ সেকেন্ড চালু আছে। Brightness/volume swipe শুধু landscape-এ; portrait-এ hardware volume button কাজ করে। + +5. **Quality ও audio** — উৎসে যত quality/track পাওয়া যায়, player সেগুলো বেছে চালায়; আলাদা high-quality video+audio stream একই timeline-এ sync থাকে। সব ভিডিওতে 2K/4K বা নির্দিষ্ট quality নিশ্চিত নয় — source, internet ও device-এর ওপর নির্ভর করে। + +6. **Video/Audio switch** — একই playback রেখে দ্রুত view বদলায়। Audio player-এ Download-এর পাশে Timer; video page-এ Background play-এর জায়গায় Timer। Countdown শেষে audio/video থামে। + +7. **Downloads ও Library** — progress, percentage, মোট MB, speed/ETA যেখানে পাওয়া যায়; pause/resume, share, completed audio file সরাসরি খোলা, album-art library ও item actions আছে। + +8. **Notification ও PiP** — Previous/Play-Pause/Next controls আছে; notification tap delay কমানোর code fix হয়েছে। অন্য video চালালে আগের PiP বন্ধ হয়, black floating window আটকে থাকার fix-ও আছে। diff --git a/VIDEO_COLLAPSE_BN.md b/VIDEO_COLLAPSE_BN.md new file mode 100644 index 0000000..89b1cc5 --- /dev/null +++ b/VIDEO_COLLAPSE_BN.md @@ -0,0 +1,29 @@ +# SparkTube 1.4.1 — ভিডিও প্লেয়ার ছোট করার বাটন + +ভিডিও প্লেয়ারের উপরের বাঁ কোণে অডিও প্লেয়ারের মতো নিচমুখী শেভরন বাটন যোগ হয়েছে। একই `finish()` অ্যাকশন ভিডিও পেজ বন্ধ করে আগের স্ক্রিনের মিনি প্লেয়ারে ফেরায়; বাটনটি প্লেব্যাক বন্ধ, কিউ পরিবর্তন, অডিও মোডে রূপান্তর বা PiP চালু করে না। বিদ্যমান slide-down transition ও shared-player lifecycle ব্যবহৃত হয়। + +বাটনের টাচ এরিয়া 48×48dp। এটি ভিডিওর ভেতরে থাকে, transport controls লুকালেও দেখা যায়, PiP-এ লুকায় এবং PiP থেকে ফিরলে আবার আসে। Portrait-এ status-bar inset দুবার যোগ করা হয় না; fullscreen-এ cutout/system-bar inset মানা হয়। বাটনটি অডিওর একই down-chevron আইকন ব্যবহার করে; ভিডিওতে contrast-এর জন্য বিদ্যমান dark scrim রাখা হয়েছে। + +## যাচাই + +`verification/video-collapse/`-এ বিল্ড লগ, source/XML checks, JVM test results, APK metadata, signing ও alignment verification এবং পরিবর্তনের diff আছে। Source/XML checks Android UI execution নয়। Package, versionName 1.4.1, versionCode 10, dependencies ও compact-test build configuration অপরিবর্তিত। আগের notification-audio test APK-এর signing certificate-এর সঙ্গে মিল পরীক্ষা করা হয়েছে। অ্যাপের আগের সোর্সের তুলনায় শুধু PlayerActivity.kt, activity_player.xml ও strings_player_controls.xml বদলেছে; playback service, notification, downloads, audio ও queue implementation অপরিবর্তিত। + +কোনো Android ডিভাইস বা emulator সংযুক্ত নেই। Install/update, বাটনে বাস্তব ট্যাপ, seamless playback, orientation, cutout, accessibility ও PiP ফেরার device test করা হয়নি। অফলাইন lint dependency সীমাবদ্ধতার কারণে lintVital tasks বাদ দিয়ে build করা হয়েছে; lint পাস দাবি করা হচ্ছে না। + +## ফোনে পরীক্ষা + +1. ভিডিও চালিয়ে বাঁ দিকের নিচমুখী বাটন চাপুন: আগের স্ক্রিন ও মিনি প্লেয়ার আসবে; একই ভিডিও ও কিউ চলবে। মিনি প্লেয়ার খুলে অবস্থান মিলিয়ে দেখুন। +2. Pause করে এবং buffering-এর সময় বাটন চাপুন: paused/play intent অপরিবর্তিত থাকবে। +3. Controls auto-hide হওয়ার পরেও বাটন পরীক্ষা করুন। Fullscreen, rotation ও notch/cutout-এ দৃশ্যমানতা ও touch target দেখুন। +4. PiP-এ বাটন থাকবে না; PiP থেকে ফিরে বাটন আসবে। অডিও/ভিডিও switch, notification Play/Pause, seek, queue ও downloads-এর আগের আচরণও দেখুন। + +## সোর্স থেকে বিল্ড + +JDK 21 ও Android SDK দিয়ে নিজের local.properties তৈরি করে চালান: + +```sh +./gradlew :app:assembleCompactTest :app:testCompactTestUnitTest +python3 tools/verify-current-update.py +``` + +এই পরিবেশে `--offline --rerun-tasks -x :app:lintVitalAnalyzeCompactTest -x :app:lintVitalReportCompactTest -x :app:lintVitalCompactTest` দিয়ে Gradle চালানো হয়েছে। ZIP-এ editable source, Gradle wrapper, vendored libraries, JVM tests ও বর্তমান verification evidence আছে; machine-local SDK path, build caches, APK ও signing key নেই। নিজের সোর্স বিল্ডের signing key আলাদা হতে পারে। ঐতিহাসিক source-scope check-এর জন্য আগের notification-audio source ZIP প্রয়োজন, APK build বা JVM tests-এর জন্য নয়। diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0940fb1..f9de217 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -15,8 +15,8 @@ android { applicationId = "com.sparktube.app" minSdk = 24 targetSdk = 34 - versionCode = 10 - versionName = "1.4.1" + versionCode = 11 + versionName = "1.4.2" } // Release signing: credentials come from environment variables so they @@ -51,6 +51,20 @@ android { signingConfig = signingConfigs.getByName("release") } } + // Compact test APK: use D8's non-debug dex packaging, but keep every + // class/resource and the existing test-build behavior. No R8 shrinking, + // obfuscation, language filtering, ABI filtering or feature removal. + create("compactTest") { + initWith(getByName("debug")) + isDebuggable = false + isMinifyEnabled = false + isShrinkResources = false + signingConfig = signingConfigs.getByName("debug") + matchingFallbacks += listOf("debug") + // Keep the debug test's update-check/crash-report behavior even + // though this compact APK does not allow debugger attachment. + buildConfigField("boolean", "DEBUG", "true") + } } compileOptions { @@ -80,6 +94,7 @@ kotlin { dependencies { val media3Version = "1.3.1" + testImplementation(files("libs/test/junit-4.13.2.jar", "libs/test/hamcrest-core-1.3.jar")) implementation("androidx.core:core-ktx:1.13.1") implementation("androidx.appcompat:appcompat:1.7.0") implementation("com.google.android.material:material:1.12.0") diff --git a/app/libs/test/hamcrest-core-1.3.jar b/app/libs/test/hamcrest-core-1.3.jar new file mode 100644 index 0000000..9d5fe16 Binary files /dev/null and b/app/libs/test/hamcrest-core-1.3.jar differ diff --git a/app/libs/test/junit-4.13.2.jar b/app/libs/test/junit-4.13.2.jar new file mode 100644 index 0000000..6da55d8 Binary files /dev/null and b/app/libs/test/junit-4.13.2.jar differ diff --git a/app/src/main/java/com/sparktube/app/data/ProgressiveFeedLoader.kt b/app/src/main/java/com/sparktube/app/data/ProgressiveFeedLoader.kt new file mode 100644 index 0000000..948816f --- /dev/null +++ b/app/src/main/java/com/sparktube/app/data/ProgressiveFeedLoader.kt @@ -0,0 +1,39 @@ +package com.sparktube.app.data + +import java.io.IOException +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope + +/** Bounded feed fan-out: publish the first useful list without waiting for slower sources. */ +internal object ProgressiveFeedLoader { + suspend fun load( + loaders: List List>, + onInitialItems: suspend (List) -> Unit + ): List> = coroutineScope { + val published = AtomicBoolean(false) + val lastError = AtomicReference(null) + val results = loaders.map { loader -> + async(Dispatchers.IO) { + val items = try { + loader() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + lastError.set(e) + emptyList() + } + if (items.isNotEmpty() && published.compareAndSet(false, true)) { + onInitialItems(items) + } + items + } + }.awaitAll().filter { it.isNotEmpty() } + if (results.isEmpty()) throw lastError.get() ?: IOException("No content available for this region") + results + } +} diff --git a/app/src/main/java/com/sparktube/app/data/YtRepository.kt b/app/src/main/java/com/sparktube/app/data/YtRepository.kt index 2bb926f..7fda5b7 100644 --- a/app/src/main/java/com/sparktube/app/data/YtRepository.kt +++ b/app/src/main/java/com/sparktube/app/data/YtRepository.kt @@ -116,24 +116,17 @@ object YtRepository { NewPipe.setPreferredContentCountry(ContentCountry(countryCode)) } - /** Country based suggestion feed used on the Home tab. */ - suspend fun trending(countryCode: String): List = + /** Country feed; first useful cards can render before all three kiosks finish. */ + suspend fun trending( + countryCode: String, + onInitialItems: suspend (List) -> Unit = {} + ): List = withContext(Dispatchers.IO) { NewPipe.setPreferredContentCountry(ContentCountry(countryCode)) - var lastError: Exception? = null - val lists = HOME_KIOSKS.mapNotNull { kioskId -> - try { - fetchKiosk(kioskId) - } catch (e: Exception) { - lastError = e - emptyList() - } - }.filter { it.isNotEmpty() } - - if (lists.isEmpty()) { - throw lastError ?: IOException("No content available for this region") - } + val loaders: List List> = + HOME_KIOSKS.map { kioskId -> suspend { fetchKiosk(kioskId) } } + val lists = ProgressiveFeedLoader.load(loaders, onInitialItems) // Round-robin blend so the feed feels varied and general. val blended = mutableListOf() @@ -173,9 +166,10 @@ object YtRepository { */ suspend fun personalizedFeed( countryCode: String, - snap: RecommendEngine.Snapshot + snap: RecommendEngine.Snapshot, + onInitialItems: suspend (List) -> Unit = {} ): List = withContext(Dispatchers.IO) { - val trending = trending(countryCode) + val trending = trending(countryCode, onInitialItems) if (!snap.hasSignal) { return@withContext trending } diff --git a/app/src/main/java/com/sparktube/app/download/DownloadCenter.kt b/app/src/main/java/com/sparktube/app/download/DownloadCenter.kt index 6c5f564..d726920 100644 --- a/app/src/main/java/com/sparktube/app/download/DownloadCenter.kt +++ b/app/src/main/java/com/sparktube/app/download/DownloadCenter.kt @@ -13,7 +13,10 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.launch +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request @@ -50,6 +53,7 @@ object DownloadCenter { const val STATUS_PENDING = "PENDING" const val STATUS_RUNNING = "RUNNING" + const val STATUS_PAUSED = "PAUSED" const val STATUS_DONE = "DONE" const val STATUS_FAILED = "FAILED" @@ -85,7 +89,7 @@ object DownloadCenter { private val mainHandler = Handler(Looper.getMainLooper()) - private val jobIdSeq = AtomicLong(1L) + private val jobIdSeq = AtomicLong(System.currentTimeMillis()) /** Live jobs by internal job id — the replacement for DownloadManager ids. */ private val jobs = ConcurrentHashMap() @@ -104,7 +108,7 @@ object DownloadCenter { fun hasActiveDownload(context: Context, videoUrl: String): Boolean = recordsFor(context, videoUrl).any { - it.status == STATUS_PENDING || it.status == STATUS_RUNNING + it.status == STATUS_PENDING || it.status == STATUS_RUNNING || it.status == STATUS_PAUSED } /** @@ -113,21 +117,32 @@ object DownloadCenter { */ fun downloadProgress(context: Context, videoUrl: String): Int { val active = recordsFor(context, videoUrl) - .filter { it.status == STATUS_PENDING || it.status == STATUS_RUNNING } + .filter { it.status == STATUS_PENDING || it.status == STATUS_RUNNING || it.status == STATUS_PAUSED } if (active.isEmpty()) return -1 - var done = 0L - var total = 0L - active.forEach { record -> - record.downloadIds.forEach { id -> - val job = jobs[id] - if (job != null) { - done += job.progress.get() - total += job.size - } - } + val snapshots = active.map(::metrics) + return calculateDownloadMetrics( + snapshots.sumOf { it.downloadedBytes }, snapshots.map { it.totalBytes }, 0.0 + ).progressPercent + } + + /** Progress for the live jobs belonging to one record, or zero if unknown. */ + fun progressPercent(record: DownloadRecord): Int { + return metrics(record).progressPercent + } + + /** Aggregate byte/rate/ETA metrics for a record, including paired AV jobs. */ + fun metrics(record: DownloadRecord): DownloadMetrics { + val live = record.downloadIds.mapNotNull { jobs[it] } + if (live.isEmpty()) { + val lengths = record.filePaths.map { path -> File(path).takeIf { it.isFile }?.length() } + val downloaded = lengths.filterNotNull().sum() + val total = if (lengths.isNotEmpty() && lengths.all { it != null }) lengths.filterNotNull().sum() else null + return calculateDownloadMetrics(downloaded, listOf(total), 0.0) } - if (total <= 0L) return 0 - return ((done * 100) / total).toInt().coerceIn(0, 100) + val downloaded = live.sumOf { it.progress.get() } + val totals = record.downloadIds.map { id -> jobs[id]?.size?.takeIf { size -> size > 0L } } + val rate = live.filterNot { it.finished.get() || it.paused.get() }.sumOf { it.bytesPerSecond() } + return calculateDownloadMetrics(downloaded, totals, rate) } /** Deletes every download (files + registry). */ @@ -233,6 +248,24 @@ object DownloadCenter { audioJob.start() } + /** Pause/resume live transfers; partial files and byte counters are kept in memory/on disk. */ + fun togglePause(context: Context, record: DownloadRecord) = synchronized(syncLock) { + val active = record.downloadIds.mapNotNull { jobs[it] } + if (active.isEmpty() || active.all { it.finished.get() } || active.any { it.failed.get() }) { + syncLocked(context) + return@synchronized + } + val pause = !active.filterNot { it.finished.get() }.all { it.paused.get() } + active.forEach { + it.resetSpeedSample() + it.paused.set(pause) + } + LocalStore.updateDownloadStatus(context, record.downloadIds.first(), if (pause) STATUS_PAUSED else STATUS_RUNNING) + } + + /** Legacy compatibility: decimal megabits/sec as before. */ + fun speedMbps(record: DownloadRecord): Double = metrics(record).bytesPerSecond * 8.0 / 1_000_000.0 + /** Deletes the files of a record and removes it from the registry. */ fun delete(context: Context, record: DownloadRecord) = synchronized(syncLock) { record.downloadIds.forEach { id -> @@ -283,13 +316,17 @@ object DownloadCenter { recordJobs.isEmpty() -> STATUS_FAILED recordJobs.any { it.failed.get() } -> STATUS_FAILED recordJobs.all { it.finished.get() } -> STATUS_DONE + recordJobs.filterNot { it.finished.get() }.all { it.paused.get() } -> STATUS_PAUSED else -> STATUS_RUNNING } if (newStatus != record.status) { LocalStore.updateDownloadStatus(context, record.downloadIds.first(), newStatus) if (newStatus == STATUS_DONE || newStatus == STATUS_FAILED) { // Terminal state: now the live jobs can go. - recordJobs.forEach { jobs.remove(it.id) } + recordJobs.forEach { + jobs.remove(it.id) + if (newStatus == STATUS_FAILED) it.cancel() + } if (newStatus == STATUS_DONE) { announceDone(context) } @@ -323,6 +360,20 @@ object DownloadCenter { private var job: Job? = null private val cancelled = AtomicBoolean(false) + val paused = AtomicBoolean(false) + private val speedSampler = DownloadSpeedSampler() + fun bytesPerSecond(): Double = speedSampler.bytesPerSecond(System.nanoTime()) + fun resetSpeedSample() { + speedSampler.reset(System.nanoTime(), progress.get()) + } + private suspend fun awaitActive() { + while (paused.get() && !cancelled.get()) kotlinx.coroutines.delay(100) + kotlinx.coroutines.currentCoroutineContext().ensureActive() + } + private fun countBytes(n: Int) { + val cumulative = progress.addAndGet(n.toLong()) + speedSampler.onBytes(cumulative, System.nanoTime()) + } fun start() { file.parentFile?.mkdirs() @@ -333,6 +384,9 @@ object DownloadCenter { .onFailure { Log.w(TAG, "Download job #$id failed for $url", it) } .getOrElse { false } if (ok && !cancelled.get()) { + // An unknown-length component now has an exact final size; + // keep it in a paired record's aggregate while its peer runs. + if (size <= 0L) size = progress.get() finished.set(true) } else { failed.set(true) @@ -367,9 +421,10 @@ object DownloadCenter { totalSize = contentRange ?.substringAfterLast('/') ?.toLongOrNull() - ?: -1L - rangesSupported = (contentRange != null) || - (acceptRanges?.equals("bytes", ignoreCase = true) == true) + ?: if (resp.code == 200) resp.body?.contentLength()?.takeIf { it > 0L } ?: -1L else -1L + // Accept-Ranges is only an advertisement; a 200 response to + // our Range probe means ranges were not actually honored. + rangesSupported = resp.code == 206 && contentRange != null } if (totalSize <= 0L) { @@ -379,7 +434,11 @@ object DownloadCenter { } size = totalSize - if (!rangesSupported || totalSize < MIN_SIZE_FOR_SPLIT) { + if (!rangesSupported) { + size = totalSize + return@withContext plainDownload() + } + if (totalSize < MIN_SIZE_FOR_SPLIT) { return@withContext segmentedDownload(0L, totalSize - 1L) } @@ -393,14 +452,13 @@ object DownloadCenter { RandomAccessFile(file, "rw").use { raf -> raf.setLength(totalSize) } - val results = bounds.map { (start, end) -> - launchSegment(start, end) + return@withContext coroutineScope { + val results = bounds.map { (start, end) -> launchSegment(start, end) } + results.map { segJob -> + segJob.job.join() + segJob.completed.get() + }.all { it } } - // Join every segment in order; collect their success flags. - results.map { segJob -> - segJob.job.join() - segJob.completed.get() - }.all { it } } private fun newRequest(): Request.Builder = @@ -418,21 +476,27 @@ object DownloadCenter { return@withContext false } val body = resp.body ?: return@withContext false + // Full GET length can be known even when the probe omitted + // Content-Range. Never use a one-byte 206 probe's length. + size = body.contentLength().takeIf { it > 0L } ?: 0L file.outputStream().use { out -> body.byteStream().use { input -> val buf = ByteArray(64 * 1024) while (true) { if (cancelled.get()) return@withContext false + awaitActive() val read = input.read(buf) if (read < 0) break out.write(buf, 0, read) - progress.addAndGet(read.toLong()) + countBytes(read) } out.flush() } } } true + } catch (e: kotlinx.coroutines.CancellationException) { + throw e } catch (e: Exception) { Log.w(TAG, "Plain download error for $url", e) false @@ -440,41 +504,52 @@ object DownloadCenter { } /** One contiguous byte range, written at its offset. Retries a few times. */ - private fun launchSegment(start: Long, end: Long): SegmentJob { + private fun kotlinx.coroutines.CoroutineScope.launchSegment(start: Long, end: Long): SegmentJob { val completedFlag = AtomicBoolean(false) - val segJob = DownloadCenter.scope.launch(Dispatchers.IO) { + val segJob = launch(Dispatchers.IO) { var attempt = 0 + var written = 0L while (attempt <= CHUNK_RETRIES && !cancelled.get()) { try { + awaitActive() + currentCoroutineContext().ensureActive() val request = newRequest() - .header("Range", "bytes=$start-$end") + .header("Range", "bytes=${start + written}-$end") .build() DownloadCenter.client.newCall(request).execute().use { resp -> - if (!resp.isSuccessful) throw IOException("HTTP ${resp.code}") + val range = resp.header("Content-Range") + val match = range?.let { Regex("bytes (\\d+)-(\\d+)/(\\d+|\\*)").matchEntire(it) } + if (resp.code != 206 || match == null || + match.groupValues[1].toLongOrNull() != start + written || + match.groupValues[2].toLongOrNull() != end || + (match.groupValues[3] != "*" && match.groupValues[3].toLongOrNull() != size)) { + throw IOException("Invalid range response HTTP ${resp.code}: $range") + } val body = resp.body ?: throw IOException("Empty body") RandomAccessFile(file, "rw").use { raf -> - raf.seek(start) + raf.seek(start + written) val input = body.byteStream() val buf = ByteArray(64 * 1024) - var written = 0L - val expected = end - start + 1 + val expected = end - start + 1L while (written < expected) { - if (cancelled.get()) return@launch + if (cancelled.get()) throw kotlinx.coroutines.CancellationException("Download cancelled") + awaitActive() val read = input.read(buf, 0, minOf(buf.size.toLong(), expected - written).toInt()) if (read < 0) throw IOException("Unexpected end of stream") raf.write(buf, 0, read) written += read.toLong() - progress.addAndGet(read.toLong()) + countBytes(read) } } completedFlag.set(true) return@launch } + } catch (e: kotlinx.coroutines.CancellationException) { + throw e } catch (e: Exception) { attempt++ if (attempt > CHUNK_RETRIES) break - // Exponential-ish backoff between retries. - try { Thread.sleep(300L * attempt) } catch (_: InterruptedException) {} + kotlinx.coroutines.delay(300L * attempt) } } } @@ -484,9 +559,11 @@ object DownloadCenter { /** Single-range convenience wrapper (small files). */ private suspend fun segmentedDownload(start: Long, end: Long): Boolean { RandomAccessFile(file, "rw").use { raf -> raf.setLength(end + 1) } - val seg = launchSegment(start, end) - seg.job.join() - return seg.completed.get() + return coroutineScope { + val seg = launchSegment(start, end) + seg.job.join() + seg.completed.get() + } } } diff --git a/app/src/main/java/com/sparktube/app/download/DownloadMetrics.kt b/app/src/main/java/com/sparktube/app/download/DownloadMetrics.kt new file mode 100644 index 0000000..f02ab17 --- /dev/null +++ b/app/src/main/java/com/sparktube/app/download/DownloadMetrics.kt @@ -0,0 +1,73 @@ +package com.sparktube.app.download + +/** Pure transfer metrics helpers; time values are monotonic nanoseconds. */ +data class DownloadMetrics( + val downloadedBytes: Long, + val totalBytes: Long?, + val bytesPerSecond: Double, + val remainingSeconds: Long? +) { + val progressPercent: Int + get() = totalBytes?.takeIf { it > 0L } + ?.let { (downloadedBytes.coerceAtLeast(0L).coerceAtMost(it).toDouble() / it.toDouble() * 100.0).toInt().coerceIn(0, 100) } + ?: 0 +} + +/** + * Samples cumulative byte-counter updates, not UI getter calls. A gap with no + * transfer updates is considered a stall and reports zero speed. + */ +internal class DownloadSpeedSampler( + private val minSampleNanos: Long = 250_000_000L, + private val staleAfterNanos: Long = 1_500_000_000L +) { + private var lastBytes = 0L + private var lastUpdateNanos: Long? = null + private var rate = 0.0 + + @Synchronized + fun onBytes(cumulativeBytes: Long, nowNanos: Long) { + // Parallel writers may reach this monitor out of counter-update order. + if (cumulativeBytes < lastBytes) return + val previousTime = lastUpdateNanos + if (previousTime != null && nowNanos > previousTime) { + val elapsed = nowNanos - previousTime + if (elapsed >= minSampleNanos) { + rate = ((cumulativeBytes - lastBytes).coerceAtLeast(0L)).toDouble() * 1_000_000_000.0 / elapsed + lastBytes = cumulativeBytes + lastUpdateNanos = nowNanos + return + } + } + if (previousTime == null) { + lastBytes = cumulativeBytes + lastUpdateNanos = nowNanos + } + } + + @Synchronized + fun bytesPerSecond(nowNanos: Long): Double = + if (lastUpdateNanos == null || nowNanos - lastUpdateNanos!! > staleAfterNanos) 0.0 else rate + + @Synchronized + fun reset(nowNanos: Long, cumulativeBytes: Long = 0L) { + lastBytes = cumulativeBytes + lastUpdateNanos = nowNanos + rate = 0.0 + } +} + +internal fun calculateDownloadMetrics( + downloadedBytes: Long, + totals: List, + bytesPerSecond: Double +): DownloadMetrics { + val validTotals = totals.filterNotNull().filter { it > 0L } + // Any missing component makes the aggregate total unknown. + val total = if (totals.isNotEmpty() && totals.all { it != null && it > 0L }) validTotals.sum() else null + val downloaded = downloadedBytes.coerceAtLeast(0L) + val eta = if (total != null && bytesPerSecond > 0.0 && bytesPerSecond.isFinite()) { + kotlin.math.ceil((total - downloaded).coerceAtLeast(0L).toDouble() / bytesPerSecond).toLong() + } else null + return DownloadMetrics(downloaded, total, bytesPerSecond.takeIf { it.isFinite() && it > 0.0 } ?: 0.0, eta) +} diff --git a/app/src/main/java/com/sparktube/app/download/DownloadSharing.kt b/app/src/main/java/com/sparktube/app/download/DownloadSharing.kt new file mode 100644 index 0000000..5fc8b92 --- /dev/null +++ b/app/src/main/java/com/sparktube/app/download/DownloadSharing.kt @@ -0,0 +1,50 @@ +package com.sparktube.app.download + +import android.content.ClipData +import android.content.Context +import android.content.Intent +import android.widget.Toast +import androidx.core.content.FileProvider +import com.sparktube.app.R +import com.sparktube.app.data.DownloadRecord +import java.io.File + +object DownloadSharing { + fun share(context: Context, record: DownloadRecord) { + val files = record.filePaths.map(::File).filter { it.isFile && it.canRead() } + if (files.isEmpty()) { + Toast.makeText(context, R.string.download_file_missing, Toast.LENGTH_SHORT).show() + return + } + try { + val uris = ArrayList(files.map { + FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", it) + }) + val intent = if (uris.size == 1) Intent(Intent.ACTION_SEND).apply { + type = mime(files.first()) + putExtra(Intent.EXTRA_STREAM, uris.first()) + } else Intent(Intent.ACTION_SEND_MULTIPLE).apply { + // AV downloads are two separate tracks, not a muxed movie. + type = "*/*" + putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris) + } + intent.clipData = ClipData.newUri(context.contentResolver, record.title, uris.first()).apply { + uris.drop(1).forEach { addItem(ClipData.Item(it)) } + } + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + context.startActivity(Intent.createChooser(intent, context.getString(R.string.download_share))) + } catch (_: Exception) { + Toast.makeText(context, R.string.download_share_unavailable, Toast.LENGTH_SHORT).show() + } + } + + private fun mime(file: File): String = when (file.extension.lowercase()) { + "mp4", "m4v" -> "video/mp4" + "m4a" -> "audio/mp4" + "mp3" -> "audio/mpeg" + "aac" -> "audio/aac" + "ogg", "opus" -> "audio/ogg" + "wav" -> "audio/wav" + else -> "application/octet-stream" + } +} diff --git a/app/src/main/java/com/sparktube/app/playback/NotificationTransport.kt b/app/src/main/java/com/sparktube/app/playback/NotificationTransport.kt new file mode 100644 index 0000000..db5dc14 --- /dev/null +++ b/app/src/main/java/com/sparktube/app/playback/NotificationTransport.kt @@ -0,0 +1,37 @@ +package com.sparktube.app.playback + +import androidx.media3.common.Player + +/** Notification taps are discrete commands, not headset single/double-tap gestures. */ +internal object NotificationTransport { + // Android KeyEvent constants; kept numeric here so the dispatch policy is JVM-testable. + private const val ACTION_DOWN = 0 + private const val KEYCODE_MEDIA_PLAY_PAUSE = 85 + + fun handle( + player: Player, + notificationController: Boolean, + action: Int, + keyCode: Int, + repeatCount: Int + ): Boolean { + if (!notificationController || action != ACTION_DOWN || keyCode != KEYCODE_MEDIA_PLAY_PAUSE) { + return false + } + // Do not turn held-key repeats into more toggles or let them enter Media3's timer. + if (repeatCount != 0 || !player.isCommandAvailable(Player.COMMAND_PLAY_PAUSE)) return true + val state = player.playbackState + if (player.playWhenReady && state != Player.STATE_IDLE && state != Player.STATE_ENDED) { + player.pause() + } else { + // Preserve native Play behavior for an idle or completed source. + if (state == Player.STATE_IDLE && player.isCommandAvailable(Player.COMMAND_PREPARE)) { + player.prepare() + } else if (state == Player.STATE_ENDED && player.isCommandAvailable(Player.COMMAND_SEEK_TO_DEFAULT_POSITION)) { + player.seekToDefaultPosition() + } + player.play() + } + return true // Consumed: Media3 must not delay or execute this tap a second time. + } +} diff --git a/app/src/main/java/com/sparktube/app/playback/PendingPlaybackRequest.kt b/app/src/main/java/com/sparktube/app/playback/PendingPlaybackRequest.kt new file mode 100644 index 0000000..81a96ae --- /dev/null +++ b/app/src/main/java/com/sparktube/app/playback/PendingPlaybackRequest.kt @@ -0,0 +1,32 @@ +package com.sparktube.app.playback + +/** User intent while a video URL is resolving; stale jobs may not clear a newer request. */ +internal class PendingPlaybackRequest { + private var generation = 0L + var active = false + private set + var playWhenReady = true + private set + + fun begin(): Long { + generation++ + active = true + playWhenReady = true + return generation + } + + fun setPlayWhenReady(value: Boolean) { + playWhenReady = value + } + + fun complete(token: Long): Boolean { + if (token != generation || !active) return false + active = false + return true + } + + fun cancel() { + generation++ + active = false + } +} diff --git a/app/src/main/java/com/sparktube/app/playback/PipSessionState.kt b/app/src/main/java/com/sparktube/app/playback/PipSessionState.kt new file mode 100644 index 0000000..3af5b7b --- /dev/null +++ b/app/src/main/java/com/sparktube/app/playback/PipSessionState.kt @@ -0,0 +1,28 @@ +package com.sparktube.app.playback + +/** Main-thread PiP ownership. Late callbacks from a retired page cannot clear a new owner. */ +internal class PipSessionState { + private var owner: T? = null + private var closeOwner: (() -> Unit)? = null + val active: Boolean get() = owner != null + + fun claim(candidate: T, onClose: () -> Unit) { + if (owner !== candidate) close() + owner = candidate + closeOwner = onClose + } + + fun release(candidate: T) { + if (owner !== candidate) return + owner = null + closeOwner = null + } + + fun close() { + val close = closeOwner + // Clear before invoking Android lifecycle code: finish can re-enter callbacks. + owner = null + closeOwner = null + close?.invoke() + } +} diff --git a/app/src/main/java/com/sparktube/app/playback/PlaybackCenter.kt b/app/src/main/java/com/sparktube/app/playback/PlaybackCenter.kt index 2545fc6..71de64a 100644 --- a/app/src/main/java/com/sparktube/app/playback/PlaybackCenter.kt +++ b/app/src/main/java/com/sparktube/app/playback/PlaybackCenter.kt @@ -17,6 +17,7 @@ import androidx.media3.datasource.DataSpec import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.datasource.ResolvingDataSource +import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory @@ -185,6 +186,9 @@ object PlaybackCenter { fun onCatalogReady() {} fun onPlaybackStateChanged(isPlaying: Boolean) {} fun onQueueChanged() {} + fun onSleepTimerChanged() {} + fun onSleepTimerExpired() {} + fun onVideoFrameRendered() {} fun onError(message: String) {} /** A resolved item turned out to be live content — see [LiveContentException]. */ @@ -210,7 +214,75 @@ object PlaybackCenter { private const val RADIO_REFILL_BELOW = 10 private const val RADIO_MIN_UPCOMING = 15 private var playerRef: ExoPlayer? = null + private val sleepTimer = SleepTimerState() + private var sleepTimerJob: Job? = null + private val timerHandler = android.os.Handler(android.os.Looper.getMainLooper()) + private val timerAlarm = android.app.AlarmManager.OnAlarmListener { checkSleepTimer() } + val sleepTimerMode: SleepTimerState.Mode get() = sleepTimer.mode + var sleepTimerMinutes: Int = 0 + private set + val sleepTimerRemainingMs: Long? get() = sleepTimer.remainingMs(SystemClock.elapsedRealtime()) + + /** Shared timer survives page collapse, mode changes, pause and background playback. */ + fun setSleepTimer(minutes: Int) { + require(minutes in listOf(10, 15, 20, 30, 45, 60)) + clearSleepTimer() + sleepTimer.setDuration(minutes, SystemClock.elapsedRealtime()) + sleepTimerMinutes = minutes + // Listener alarms need no exact-alarm permission and wake the live playback process. + // The monotonic coroutine is also retained as a foreground/service fallback. + try { + val alarms = appContext.getSystemService(Context.ALARM_SERVICE) as android.app.AlarmManager + alarms.setExact(android.app.AlarmManager.ELAPSED_REALTIME_WAKEUP, + sleepTimer.deadlineMs!!, "SparkTube sleep timer", timerAlarm, timerHandler) + } catch (_: SecurityException) { /* OEM restriction: service ticker still checks. */ } + sleepTimerJob = scope.launch { + while (isActive && sleepTimer.mode == SleepTimerState.Mode.DURATION) { + if (checkSleepTimer()) break + delay(minOf(sleepTimerRemainingMs ?: 1000L, 1000L).coerceAtLeast(1L)) + } + } + notify { it.onSleepTimerChanged() } + } + + fun setSleepTimerEndOfItem() { + if (!hasMedia) return + clearSleepTimer() + sleepTimer.setEndOfItem(currentEntry?.url) + // Pause before the native music playlist can start its next item. + applyMusicAutoplay() + notify { it.onSleepTimerChanged() } + if (playerRef?.playbackState == Player.STATE_ENDED) expireSleepTimer() + } + + fun clearSleepTimer() { + sleepTimerJob?.cancel() + sleepTimerJob = null + if (this::appContext.isInitialized) { + (appContext.getSystemService(Context.ALARM_SERVICE) as android.app.AlarmManager).cancel(timerAlarm) + } + sleepTimer.clear() + sleepTimerMinutes = 0 + applyMusicAutoplay() + notify { it.onSleepTimerChanged() } + } + + private fun checkSleepTimer(): Boolean { + if (!sleepTimer.isExpired(SystemClock.elapsedRealtime())) return false + expireSleepTimer() + return true + } + + private fun expireSleepTimer() { + // Cancel resolving/watchdogs as well: a pending network result must not restart playback. + stopPlayback() + notify { it.onSleepTimerExpired() } + } + private var resolveJob: Job? = null + private val pendingVideo = PendingPlaybackRequest() + private val videoRequestPending: Boolean + get() = mode == Mode.VIDEO && pendingVideo.active // ----- Startup watchdog (auto quality downshift) ----- @@ -223,6 +295,9 @@ object PlaybackCenter { * stalls right after starting, it drops ONE quality tier and rebuilds the * source (keeping the position mid-play) until playback actually flows. */ + private var frameWatchJob: Job? = null + private var videoFrameSeen = false + private var frameRecoveryStep = 0 private var watchdogJob: Job? = null private var watchdogDownshifts = 0 @@ -276,7 +351,15 @@ object PlaybackCenter { @Volatile var mode: Mode = Mode.NONE private set - @Volatile var inPip: Boolean = false + private val pipSession = PipSessionState() + val inPip: Boolean get() = pipSession.active + + fun setPipState(view: PlayerView, active: Boolean, onClose: () -> Unit = {}) { + if (active) pipSession.claim(view, onClose) else pipSession.release(view) + } + + /** Explicit playback/page replacement only; automatic queue advance keeps PiP alive. */ + fun closePreviousPip() = pipSession.close() @Volatile var audioOnlyMode: Boolean = false private set @@ -353,7 +436,7 @@ object PlaybackCenter { val player: ExoPlayer get() { check(this::appContext.isInitialized) { "PlaybackCenter.init() not called" } - return playerRef ?: ExoPlayer.Builder(appContext) + return playerRef ?: ExoPlayer.Builder(appContext, DefaultRenderersFactory(appContext).setEnableDecoderFallback(true)) // IMPORTANT: player.setMediaItems() (music playlist!) builds // its MediaSources through this factory. Without it the // player falls back to its own default data source chain, @@ -398,15 +481,40 @@ object PlaybackCenter { } } + /** Stable facade for the media session and in-app player controls. */ + val sessionPlayer: Player + get() { + val raw = player + return forwardingPlayer?.takeIf { forwardingFor === raw } + ?: QueueForwardingPlayer(raw).also { + forwardingPlayer = it + forwardingFor = raw + } + } + private val playerListener = object : Player.Listener { override fun onIsPlayingChanged(isPlaying: Boolean) { notify { it.onPlaybackStateChanged(isPlaying) } } + override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { + if (!playWhenReady && reason == Player.PLAY_WHEN_READY_CHANGE_REASON_END_OF_MEDIA_ITEM && + sleepTimer.shouldStopAtEnd(currentEntry?.url)) { + expireSleepTimer() + return + } + notify { it.onPlaybackStateChanged(isPlaying) } + } + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == Player.STATE_ENDED && sleepTimer.shouldStopAtEnd(currentEntry?.url)) { + expireSleepTimer() + return + } + notify { it.onPlaybackStateChanged(isPlaying) } // A playlist always continues to its next video (like YouTube); // the autoplay setting only governs the related-videos queue. - if (playbackState == Player.STATE_ENDED && mode == Mode.VIDEO && + if (!videoRequestPending && playbackState == Player.STATE_ENDED && mode == Mode.VIDEO && (playlistMode || AppPrefs.videoAutoplayNext) ) { advance() @@ -415,6 +523,11 @@ object PlaybackCenter { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { if (mode != Mode.AUDIO) return + if (sleepTimer.shouldStopOnTransition(currentEntry?.url, + reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO || reason == Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT)) { + expireSleepTimer() + return + } val index = playerRef?.currentMediaItemIndex ?: return if (index < 0 || index >= queue.size) return queueIndex = index @@ -429,10 +542,22 @@ object PlaybackCenter { } override fun onPlayerError(error: PlaybackException) { - onPlayerFailed(error) + // A paused outgoing source can still fail while the next URL resolves. + if (!videoRequestPending) onPlayerFailed(error) + } + + override fun onTracksChanged(tracks: androidx.media3.common.Tracks) { + android.util.Log.i("SparkTubeVideo", "tracks videoSelected=${tracks.isTypeSelected(C.TRACK_TYPE_VIDEO)} videoSupported=${tracks.isTypeSupported(C.TRACK_TYPE_VIDEO)} adaptive=$dashActive") + } + + override fun onVideoSizeChanged(videoSize: androidx.media3.common.VideoSize) { + android.util.Log.i("SparkTubeVideo", "size=${videoSize.width}x${videoSize.height}") } override fun onRenderedFirstFrame() { + videoFrameSeen = true + frameWatchJob?.cancel() + notify { it.onVideoFrameRendered() } if (mode != Mode.VIDEO || sourceAppliedAt == 0L) return val ms = SystemClock.elapsedRealtime() - sourceAppliedAt android.util.Log.i("SparkTubeStartup", "first frame after ${ms}ms (adaptive=$dashActive)") @@ -449,6 +574,12 @@ object PlaybackCenter { } private fun notify(block: (Listener) -> Unit) { + // An explicit new item cancels only end-of-item; timed countdown spans the queue. + if (sleepTimer.mode == SleepTimerState.Mode.END_OF_ITEM && + !sleepTimer.shouldStopAtEnd(currentEntry?.url)) { + clearSleepTimer() + } + forwardingPlayer?.refreshSessionState() val snapshot = listeners.toList() snapshot.forEach(block) } @@ -485,6 +616,8 @@ object PlaybackCenter { /** Kills the current playback instantly so sounds never overlap. */ private fun stopPlayerNow() { + pendingVideo.cancel() + retainedVideoSourceForAudio = false playerRef?.run { stop() clearMediaItems() @@ -496,6 +629,7 @@ object PlaybackCenter { /** Starts a video (watch page). Builds a YouTube-like autoplay queue. */ fun playVideo(entry: QueueEntry, extraQueue: List = emptyList()) { initFromApp() + closePreviousPip() stopPlayerNow() mode = Mode.VIDEO radioMode = false @@ -514,6 +648,7 @@ object PlaybackCenter { fun playPlaylist(entries: List, startIndex: Int = 0) { initFromApp() if (entries.isEmpty()) return + closePreviousPip() stopPlayerNow() mode = Mode.VIDEO radioMode = false @@ -537,6 +672,7 @@ object PlaybackCenter { fun playMusicQueue(entries: List, startIndex: Int = 0, allowRadio: Boolean = false) { initFromApp() if (entries.isEmpty()) return + closePreviousPip() val start = startIndex.coerceIn(0, entries.size - 1) // The player playlist is the tapped song + everything after it, so // player indexes and queue indexes always match. @@ -611,12 +747,10 @@ object PlaybackCenter { } fun togglePlayPause() { - val p = playerRef ?: return - if (p.isPlaying) { - p.pause() - } else { - p.play() - } + if (playerRef == null) return + val p = sessionPlayer + // isPlaying is false during buffering; toggle the user's intent instead. + if (p.playWhenReady) p.pause() else p.play() } fun seekTo(positionMs: Long) { @@ -628,6 +762,23 @@ object PlaybackCenter { val isPlaying: Boolean get() = playerRef?.isPlaying ?: false val isBuffering: Boolean get() = playerRef?.playbackState == Player.STATE_BUFFERING + /** Retry the current item without discarding the user's queue or resolving a file URL online. */ + fun retryCurrentVideo() { + val entry = currentEntry ?: return + if (mode != Mode.VIDEO) return + frameRecoveryStep = 0 + if (entry.url.startsWith("file://")) { + val record = LocalStore.downloads(appContext).firstOrNull { + it.filePaths.firstOrNull()?.let { path -> "file://$path" == entry.url } == true + } + if (record == null || !startLocalFiles(record)) { + notify { it.onError("Downloaded file is missing") } + } + } else { + resolveCurrent() + } + } + fun setSpeed(speed: Float) { playbackSpeed = speed playerRef?.setPlaybackSpeed(speed) @@ -635,7 +786,7 @@ object PlaybackCenter { /** Music settings: pause between songs instead of auto-advancing. */ fun applyMusicAutoplay() { - playerRef?.setPauseAtEndOfMediaItems(!AppPrefs.musicAutoplayNext) + playerRef?.setPauseAtEndOfMediaItems(sleepTimer.mode == SleepTimerState.Mode.END_OF_ITEM || !AppPrefs.musicAutoplayNext) } fun next() { @@ -643,7 +794,7 @@ object PlaybackCenter { Mode.AUDIO -> playerRef?.run { if (hasNextMediaItem()) seekToNextMediaItem() } - Mode.VIDEO -> if (queueIndex < queue.size - 1) { + Mode.VIDEO -> if (QueueNavigationPolicy.hasNext(queueIndex, queue.size)) { queueIndex++ resolveCurrent() } @@ -670,6 +821,11 @@ object PlaybackCenter { } fun stopPlayback() { + pendingVideo.cancel() + retainedVideoSourceForAudio = false + clearSleepTimer() + watchdogJob?.cancel() + frameWatchJob?.cancel() resolveJob?.cancel() prefetchJob?.cancel() pendingMusic.clear() @@ -682,6 +838,7 @@ object PlaybackCenter { queue.clear() queueIndex = -1 catalog = null + setResolving(false) notify { it.onQueueChanged() } notify { it.onItemChanged(null) } notify { it.onPlaybackStateChanged(false) } @@ -703,6 +860,7 @@ object PlaybackCenter { // Explicit "Auto" beats the settings default for this session. autoPicked = height == null } + frameRecoveryStep = 0 // Explicit user action: give adaptive streams another chance even // if an earlier error had dropped us to a muxed fallback. usedFallback = false @@ -723,19 +881,54 @@ object PlaybackCenter { notify { it.onCatalogReady() } } - /** Turns the current video into an audio-only background stream. */ + private var retainedVideoSourceForAudio = false + + /** Instant presentation switch: retain the source, position and playback intent. */ fun switchToAudioOnly() { + if (mode != Mode.VIDEO || audioOnlyMode) return + retainedVideoSourceForAudio = true audioOnlyMode = true - rebuildSource(keepPosition = true) + frameWatchJob?.cancel() + watchdogJob?.cancel() + // Only the screen changes. Do not stop/prepare/seek or replace the source. notify { it.onAudioOnlyChanged(true) } } - /** Rebuilds the full video source after background-audio mode. */ + /** Reattach retained video immediately; rebuild only if an audio-only source was loaded later. */ fun switchBackToVideo() { if (mode != Mode.VIDEO) return audioOnlyMode = false + frameRecoveryStep = 0 + if (selectedHeight == -1) selectedHeight = null + if (retainedVideoSourceForAudio) { + retainedVideoSourceForAudio = false + } else { + rebuildSource(keepPosition = true) + } + notify { it.onAudioOnlyChanged(false) } + } + + /** Convert an online music item to its video without resetting position or queue. */ + suspend fun switchMusicToVideo(): Boolean { + val entry = currentEntry ?: return false + if (entry.url.startsWith("file://")) return false + val resolved = StreamCatalog(YtRepository.streamInfo(entry.url)) + if (currentEntry?.url != entry.url || (resolved.videoOnly.isEmpty() && resolved.muxed.isEmpty())) return false + resolveJob?.cancel() + prefetchJob?.cancel() + mode = Mode.VIDEO + radioMode = false + playlistMode = true + audioOnlyMode = false + catalog = resolved + selectedHeight = null + selectedAudioTrackId = null + usedFallback = false + pendingMusic.clear() rebuildSource(keepPosition = true) notify { it.onAudioOnlyChanged(false) } + notify { it.onCatalogReady() } + return true } // ----- Favorites ----- @@ -757,6 +950,11 @@ object PlaybackCenter { fun playDownload(record: DownloadRecord) { initFromApp() if (record.filePaths.isEmpty()) return + resolveJob?.cancel() + pendingVideo.cancel() + setResolving(false) + frameWatchJob?.cancel() + frameRecoveryStep = 0 mode = if (record.type == DownloadCenter.TYPE_AUDIO) Mode.AUDIO else Mode.VIDEO audioOnlyMode = record.type == DownloadCenter.TYPE_AUDIO radioMode = false @@ -788,6 +986,7 @@ object PlaybackCenter { /** Prepares and starts the finished download's files on the player. False when none exist. */ private fun startLocalFiles(record: DownloadRecord): Boolean { + retainedVideoSourceForAudio = false val sources = record.filePaths .filter { File(it).exists() } .map { path -> @@ -797,12 +996,14 @@ object PlaybackCenter { } if (sources.isEmpty()) return false val source = if (sources.size == 1) sources[0] else MergingMediaSource(*sources.toTypedArray()) + videoFrameSeen = false player.run { setMediaSource(source, 0L) prepare() - playWhenReady = true + playWhenReady = if (videoRequestPending) pendingVideo.playWhenReady else true setPlaybackSpeed(playbackSpeed) } + startFrameWatch() return true } @@ -834,6 +1035,12 @@ object PlaybackCenter { private fun resolveCurrent() { val entry = currentEntry ?: return resolveJob?.cancel() + val requestToken = pendingVideo.begin() + // Acknowledge queue selection now, not after online stream extraction. + // The previous stream must not keep sounding like the newly selected item. + playerRef?.pause() + frameWatchJob?.cancel() + frameRecoveryStep = 0 // A previous video's startup watchdog must not act on this one, and // its quality drop applies to that video only. watchdogJob?.cancel() @@ -885,7 +1092,8 @@ object PlaybackCenter { } } } finally { - setResolving(false) + // A cancelled older resolve must not clear a newer selection. + if (pendingVideo.complete(requestToken)) setResolving(false) } } } @@ -1098,13 +1306,15 @@ object PlaybackCenter { return } sourceAppliedAt = SystemClock.elapsedRealtime() + videoFrameSeen = false player.run { setMediaSource(source, 0L) prepare() - playWhenReady = true + playWhenReady = if (videoRequestPending) pendingVideo.playWhenReady else true setPlaybackSpeed(speed) } startStartupWatchdog() + startFrameWatch() } /** @@ -1130,11 +1340,16 @@ object PlaybackCenter { val dt = (now - lastPollAt).coerceAtMost(1_000) lastPollAt = now val p = playerRef ?: break + if (!p.playWhenReady) { + preparedAt = now + stallSince = 0L + continue + } when (p.playbackState) { Player.STATE_IDLE, Player.STATE_ENDED -> return@launch Player.STATE_READY -> { if (readyAt == 0L) readyAt = now - if (p.isPlaying) { + if (p.isPlaying && videoFrameSeen) { stallSince = 0L healthyMs += dt // Connection proven: 15s of uninterrupted playback. @@ -1208,18 +1423,21 @@ object PlaybackCenter { val entry = currentEntry ?: return val c = catalog ?: return val position = if (keepPosition) positionMs else 0L - val wasPlaying = playerRef?.isPlaying ?: true + val wasPlaying = playerRef?.playWhenReady ?: true val source = buildSource(entry, c) ?: return sourceAppliedAt = SystemClock.elapsedRealtime() + videoFrameSeen = false player.run { setMediaSource(source, position) prepare() - if (wasPlaying || !keepPosition) playWhenReady = true + playWhenReady = wasPlaying || !keepPosition setPlaybackSpeed(playbackSpeed) } + startFrameWatch() } private fun buildSource(entry: QueueEntry, c: StreamCatalog): MediaSource? { + retainedVideoSourceForAudio = false val metadata = MediaMetadata.Builder() .setTitle(entry.title) .setArtist(entry.uploader) @@ -1279,9 +1497,7 @@ object PlaybackCenter { if (video != null) { return progressiveSource(MediaItem.Builder().setUri(audioUri(video)).setMediaMetadata(metadata).build()) } - if (audio != null) { - return progressiveSource(MediaItem.Builder().setUri(audioUri(audio)).setMediaMetadata(metadata).build()) - } + // Never silently play audio-only while the UI says Video. return null } @@ -1293,6 +1509,8 @@ object PlaybackCenter { private fun applyVideoCap(capHeight: Int) { val p = playerRef ?: player val builder = p.trackSelectionParameters.buildUpon() + .setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, false) + .setPreferredVideoMimeTypes("video/avc", "video/x-vnd.on2.vp9", "video/av01") if (capHeight > 0) builder.setMaxVideoSize(Int.MAX_VALUE, capHeight) else builder.clearVideoSizeConstraints() p.trackSelectionParameters = builder.build() @@ -1544,20 +1762,172 @@ object PlaybackCenter { private class QueueForwardingPlayer(player: ExoPlayer) : ForwardingPlayer(player) { + private val listenerBridges = mutableMapOf() + private var lastCommands: Player.Commands? = null + private var reportedPlayWhenReady = super.getPlayWhenReady() + private var reportedPlaybackState = super.getPlaybackState() + private var reportedIsPlaying = super.isPlaying() + private var reportedMetadata = super.getMediaMetadata() + + // This is the logical queue player seen by the session. While extracting + // a new video's URL it is buffering, not still playing the old video. + override fun getPlayWhenReady(): Boolean = + if (videoRequestPending) pendingVideo.playWhenReady else super.getPlayWhenReady() + + override fun getPlaybackState(): Int = + if (videoRequestPending) Player.STATE_BUFFERING else super.getPlaybackState() + + override fun isPlaying(): Boolean = !videoRequestPending && super.isPlaying() + + override fun getMediaMetadata(): MediaMetadata { + if (!videoRequestPending) return super.getMediaMetadata() + val entry = PlaybackCenter.currentEntry ?: return super.getMediaMetadata() + return MediaMetadata.Builder() + .setTitle(entry.title) + .setArtist(entry.uploader) + .setArtworkUri(entry.thumbnailUrl.takeIf { it.isNotBlank() }?.let(Uri::parse)) + .build() + } + + override fun setPlayWhenReady(playWhenReady: Boolean) { + if (videoRequestPending) { + pendingVideo.setPlayWhenReady(playWhenReady) + refreshSessionState() + PlaybackCenter.notify { it.onPlaybackStateChanged(false) } + } else { + super.setPlayWhenReady(playWhenReady) + } + } + + override fun play() = setPlayWhenReady(true) + override fun pause() = setPlayWhenReady(false) + + /** Emit actual logical-state changes synchronously; never poll or fake READY. */ + fun refreshSessionState() { + refreshAvailableCommands() + val ready = playWhenReady + val state = playbackState + val playing = isPlaying + val metadata = mediaMetadata + val readyChanged = ready != reportedPlayWhenReady + val stateChanged = state != reportedPlaybackState + val playingChanged = playing != reportedIsPlaying + val metadataChanged = metadata != reportedMetadata + if (!readyChanged && !stateChanged && !playingChanged && !metadataChanged) return + reportedPlayWhenReady = ready + reportedPlaybackState = state + reportedIsPlaying = playing + reportedMetadata = metadata + val flags = androidx.media3.common.FlagSet.Builder() + if (readyChanged) flags.add(Player.EVENT_PLAY_WHEN_READY_CHANGED) + if (stateChanged) flags.add(Player.EVENT_PLAYBACK_STATE_CHANGED) + if (playingChanged) flags.add(Player.EVENT_IS_PLAYING_CHANGED) + if (metadataChanged) flags.add(Player.EVENT_MEDIA_METADATA_CHANGED) + val events = Player.Events(flags.build()) + val snapshot = listenerBridges.keys.toList() + snapshot.forEach { listener -> + if (readyChanged) listener.onPlayWhenReadyChanged(ready, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) + if (stateChanged) listener.onPlaybackStateChanged(state) + if (playingChanged) listener.onIsPlayingChanged(playing) + if (metadataChanged) listener.onMediaMetadataChanged(metadata) + } + snapshot.forEach { it.onEvents(this, events) } + } + + override fun addListener(listener: Player.Listener) { + if (listenerBridges.containsKey(listener)) return + // Java-default callbacks require explicit forwarding, not Kotlin `by` delegation. + val bridge = object : PlayerListenerBridge(listener) { + override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { + listener.onPlayWhenReadyChanged(this@QueueForwardingPlayer.playWhenReady, reason) + } + + override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) { + listener.onPlayerStateChanged(this@QueueForwardingPlayer.playWhenReady, this@QueueForwardingPlayer.playbackState) + } + + override fun onPlaybackStateChanged(playbackState: Int) { + listener.onPlaybackStateChanged(this@QueueForwardingPlayer.playbackState) + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + listener.onIsPlayingChanged(this@QueueForwardingPlayer.isPlaying) + } + + override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) { + listener.onMediaMetadataChanged(this@QueueForwardingPlayer.mediaMetadata) + } + + override fun onAvailableCommandsChanged(commands: Player.Commands) { + val current = this@QueueForwardingPlayer.availableCommands + lastCommands = current + listener.onAvailableCommandsChanged(current) + } + + override fun onEvents(player: Player, events: Player.Events) { + refreshSessionState() + listener.onEvents(this@QueueForwardingPlayer, events) + } + } + listenerBridges[listener] = bridge + super.addListener(bridge) + } + + override fun removeListener(listener: Player.Listener) { + val bridge = listenerBridges.remove(listener) ?: return + super.removeListener(bridge) + } + private fun queueHasNext(): Boolean = when (PlaybackCenter.mode) { - Mode.VIDEO, Mode.AUDIO -> - PlaybackCenter.queueIndex < PlaybackCenter.queue.size - 1 + Mode.VIDEO -> QueueNavigationPolicy.hasNext(PlaybackCenter.queueIndex, PlaybackCenter.queue.size) + Mode.AUDIO -> super.hasNextMediaItem() + Mode.NONE -> false + } + + private fun queueHasPrevious(): Boolean = when (PlaybackCenter.mode) { + Mode.VIDEO -> QueueNavigationPolicy.hasPrevious(PlaybackCenter.queueIndex, PlaybackCenter.queue.size) + Mode.AUDIO -> super.hasPreviousMediaItem() Mode.NONE -> false } override fun isCommandAvailable(command: Int): Boolean = when (command) { - Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM -> - queueHasNext() || super.isCommandAvailable(command) + Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM -> queueHasNext() + Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM -> queueHasPrevious() + Player.COMMAND_SEEK_TO_PREVIOUS -> when (PlaybackCenter.mode) { + Mode.VIDEO -> QueueNavigationPolicy.hasCurrent(PlaybackCenter.queueIndex, PlaybackCenter.queue.size) + Mode.AUDIO -> super.isCommandAvailable(command) + Mode.NONE -> false + } else -> super.isCommandAvailable(command) } + override fun getAvailableCommands(): Player.Commands { + val commands = super.getAvailableCommands().buildUpon() + for (command in listOf( + Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, + Player.COMMAND_SEEK_TO_PREVIOUS, Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM + )) { + if (isCommandAvailable(command)) commands.add(command) else commands.remove(command) + } + return commands.build() + } + + fun refreshAvailableCommands() { + val current = availableCommands + if (current == lastCommands) return + lastCommands = current + val snapshot = listenerBridges.keys.toList() + snapshot.forEach { it.onAvailableCommandsChanged(current) } + val events = Player.Events(androidx.media3.common.FlagSet.Builder() + .add(Player.EVENT_AVAILABLE_COMMANDS_CHANGED).build()) + snapshot.forEach { it.onEvents(this, events) } + } + override fun hasNextMediaItem(): Boolean = - queueHasNext() || super.hasNextMediaItem() + queueHasNext() + + override fun hasPreviousMediaItem(): Boolean = + queueHasPrevious() override fun seekToNext() { PlaybackCenter.next() @@ -1579,29 +1949,93 @@ object PlaybackCenter { /** The wrapper for the currently attached PlayerView + the raw player it wraps. */ private var forwardingPlayer: QueueForwardingPlayer? = null private var forwardingFor: ExoPlayer? = null + /** ExoPlayer has one video output; only this view may own it at a time. */ + private var activePlayerView: PlayerView? = null @OptIn(UnstableApi::class) fun attachView(view: PlayerView) { - val raw = playerRef ?: return - val fwd = forwardingPlayer?.takeIf { forwardingFor === raw } - ?: QueueForwardingPlayer(raw).also { - forwardingPlayer = it - forwardingFor = raw - } - view.player = fwd + val outputPlayer = player + if (activePlayerView === view && view.player === outputPlayer) return + // Rendering does not need queue-command interception/listener bridging. + // Queue-aware notification controls continue to use sessionPlayer. + videoFrameSeen = false + PlayerView.switchTargetView(outputPlayer, activePlayerView, view) + activePlayerView = view + android.util.Log.i("SparkTubeVideo", "attach ${view.javaClass.simpleName} output=${view.videoSurfaceView?.javaClass?.simpleName}") + startFrameWatch() } fun detachView(view: PlayerView) { - if (view.player === forwardingPlayer) { - view.player = null + if (activePlayerView !== view) return + activePlayerView = null + if (view.player === playerRef) view.player = null + frameWatchJob?.cancel() + } + + /** READY/isPlaying can be audio-only. Measure actual first-frame delivery on a visible output. */ + private fun startFrameWatch() { + frameWatchJob?.cancel() + if (mode != Mode.VIDEO || audioOnlyMode || videoFrameSeen) return + frameWatchJob = scope.launch { + var visibleWaitMs = 0L + var last = SystemClock.elapsedRealtime() + while (isActive) { + delay(500) + val now = SystemClock.elapsedRealtime() + val elapsed = (now - last).coerceAtMost(1_000L) + last = now + val p = playerRef ?: return@launch + val view = activePlayerView + if (videoFrameSeen || mode != Mode.VIDEO || audioOnlyMode) return@launch + // No recovery while backgrounded, hidden, paused, or waiting for network startup. + if (view == null || !view.isShown || !view.isAttachedToWindow || + view.windowVisibility != android.view.View.VISIBLE || + !p.playWhenReady || p.playbackState != Player.STATE_READY) continue + visibleWaitMs += elapsed + when (VideoFramePolicy.action(visibleWaitMs, videoFrameSeen, frameRecoveryStep)) { + VideoFramePolicy.Action.WAIT -> Unit + VideoFramePolicy.Action.REBIND_OUTPUT -> { + frameRecoveryStep++ + visibleWaitMs = 0L + android.util.Log.w("SparkTubeVideo", "No first frame: rebinding output; videoSelected=${p.currentTracks.isTypeSelected(C.TRACK_TYPE_VIDEO)} size=${p.videoSize.width}x${p.videoSize.height}") + // A view reference alone doesn't prove a live renderer output. + view.player = null + view.player = p + } + VideoFramePolicy.Action.RETRY_SOURCE -> { + frameRecoveryStep++ + val c = catalog + if (c != null) { + android.util.Log.w("SparkTubeVideo", "No first frame after rebind: retrying progressive source") + dashBlocked = true + usedFallback = c.muxed.isNotEmpty() + rebuildSource(keepPosition = true) + return@launch + } + visibleWaitMs = 0L + } + VideoFramePolicy.Action.SHOW_ERROR -> { + android.util.Log.e("SparkTubeVideo", "Video output failed after bounded recovery") + p.pause() + notify { it.onError("Video could not render on this device. Tap Retry or choose a different quality.") } + return@launch + } + } + } } } fun release() { + clearSleepTimer() + frameWatchJob?.cancel() resolveJob?.cancel() prefetchJob?.cancel() watchdogJob?.cancel() scope.cancel() + activePlayerView?.let { view -> + if (view.player === playerRef) view.player = null + } + activePlayerView = null forwardingPlayer = null forwardingFor = null playerRef?.release() diff --git a/app/src/main/java/com/sparktube/app/playback/PlaybackService.kt b/app/src/main/java/com/sparktube/app/playback/PlaybackService.kt index cd884ac..9a1f090 100644 --- a/app/src/main/java/com/sparktube/app/playback/PlaybackService.kt +++ b/app/src/main/java/com/sparktube/app/playback/PlaybackService.kt @@ -3,72 +3,54 @@ package com.sparktube.app.playback import android.app.PendingIntent import android.content.Intent import android.os.Bundle +import android.view.KeyEvent import androidx.annotation.OptIn +import androidx.core.content.IntentCompat import androidx.media3.common.util.UnstableApi -import androidx.media3.session.CommandButton +import androidx.media3.session.MediaConstants import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService import androidx.media3.session.SessionCommand import androidx.media3.session.SessionResult import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture -import com.sparktube.app.R import com.sparktube.app.ui.MainActivity import com.sparktube.app.ui.music.NowPlayingActivity /** - * Keeps playback alive in the background and shows the media notification: - * seek bar + previous / play-pause / next controls (native, straight from - * the player playlist) plus a favorite custom button. Tapping the - * notification opens the Now Playing screen for music and the main app - * otherwise. + * Background playback with native previous / play-pause / next controls. + * The session uses the app-queue-aware player, including for videos whose + * raw ExoPlayer timeline contains only the current item. Next changes tracks; + * it is not a ten-second seek. Favorites remain available inside the app. */ @OptIn(UnstableApi::class) class PlaybackService : MediaSessionService() { private var mediaSession: MediaSession? = null + private val playbackListener = object : PlaybackCenter.Listener { + override fun onItemChanged(entry: QueueEntry?) { + updateSessionActivity() + } + } override fun onCreate() { super.onCreate() PlaybackCenter.init(this) - mediaSession = MediaSession.Builder(this, PlaybackCenter.player) + mediaSession = MediaSession.Builder(this, PlaybackCenter.sessionPlayer) .setCallback(SessionCallback()) .setSessionActivity(sessionActivity()) + // Android 13+ keeps the transport slots instead of filling a + // missing Next slot with a custom favorite/heart action. + .setSessionExtras(Bundle().apply { + putBoolean(MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_PREV, true) + putBoolean(MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_NEXT, true) + }) + .setCustomLayout(emptyList()) .build() - updateCustomLayout() - PlaybackCenter.addListener(object : PlaybackCenter.Listener { - override fun onFavoriteChanged(url: String, isFavorite: Boolean) { - updateCustomLayout() - } - - override fun onItemChanged(entry: QueueEntry?) { - updateCustomLayout() - updateSessionActivity() - } - }) - } - - private fun updateCustomLayout() { - val session = mediaSession ?: return - val isFavorite = PlaybackCenter.isCurrentFavorite() - // Previous / next / seekbar come from the player itself (real - // playlist), so only the favourite toggle is a custom button. - val buttons = listOf( - CommandButton.Builder() - .setDisplayName(if (isFavorite) "Unfavorite" else "Favorite") - .setSessionCommand(SessionCommand(ACTION_FAV, Bundle.EMPTY)) - .setIconResId( - if (isFavorite) R.drawable.ic_favorite else R.drawable.ic_favorite_border - ) - .build() - ) - session.setCustomLayout(buttons) + PlaybackCenter.addListener(playbackListener) } - /** - * What tapping the notification / system media controls opens: the Now - * Playing screen while music is active, the main app otherwise. - */ + /** Opens the music view for audio, or the main app for video playback. */ private fun sessionActivity(): PendingIntent { val target = if (PlaybackCenter.mode == PlaybackCenter.Mode.AUDIO) { NowPlayingActivity::class.java @@ -92,17 +74,39 @@ class PlaybackService : MediaSessionService() { mediaSession override fun onDestroy() { + PlaybackCenter.removeListener(playbackListener) mediaSession?.release() mediaSession = null super.onDestroy() } private inner class SessionCallback : MediaSession.Callback { + override fun onMediaButtonEvent( + session: MediaSession, + controllerInfo: MediaSession.ControllerInfo, + intent: Intent + ): Boolean { + if (intent.action != Intent.ACTION_MEDIA_BUTTON) return false + val event = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_KEY_EVENT, KeyEvent::class.java) + ?: return false + // Media3 1.3.1 otherwise waits for a possible double-tap, and a + // second quick notification tap can become Next. Only bypass that + // gesture recognizer for the notification controller, not headsets. + return NotificationTransport.handle( + session.player, + session.isMediaNotificationController(controllerInfo), + event.action, + event.keyCode, + event.repeatCount + ) + } override fun onConnect( session: MediaSession, controller: MediaSession.ControllerInfo ): MediaSession.ConnectionResult { + // Retain the existing custom command API for external controllers; + // the visible notification uses only native transport controls. val sessionCommands = MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon() .add(SessionCommand(ACTION_FAV, Bundle.EMPTY)) .add(SessionCommand(ACTION_PREV, Bundle.EMPTY)) diff --git a/app/src/main/java/com/sparktube/app/playback/PlayerListenerBridge.java b/app/src/main/java/com/sparktube/app/playback/PlayerListenerBridge.java new file mode 100644 index 0000000..2d3574a --- /dev/null +++ b/app/src/main/java/com/sparktube/app/playback/PlayerListenerBridge.java @@ -0,0 +1,164 @@ +package com.sparktube.app.playback; + +/** + * Explicit forwarding is essential: Kotlin `Player.Listener by delegate` inherits + * Java default no-ops instead of forwarding them with our current Kotlin compiler. + * That swallowed onRenderedFirstFrame/onTracksChanged and left PlayerView's shutter black. + * Keep every Player.Listener callback here; PlayerListenerBridgeTest checks API coverage. + */ +@SuppressWarnings("deprecation") +class PlayerListenerBridge implements androidx.media3.common.Player.Listener { + private final androidx.media3.common.Player.Listener delegate; + + PlayerListenerBridge(androidx.media3.common.Player.Listener delegate) { + this.delegate = delegate; + } + + @Override public void onEvents(androidx.media3.common.Player arg0, androidx.media3.common.Player.Events arg1) { + delegate.onEvents(arg0, arg1); + } + + @Override public void onTimelineChanged(androidx.media3.common.Timeline arg0, int arg1) { + delegate.onTimelineChanged(arg0, arg1); + } + + @Override public void onMediaItemTransition(androidx.media3.common.MediaItem arg0, int arg1) { + delegate.onMediaItemTransition(arg0, arg1); + } + + @Override public void onTracksChanged(androidx.media3.common.Tracks arg0) { + delegate.onTracksChanged(arg0); + } + + @Override public void onMediaMetadataChanged(androidx.media3.common.MediaMetadata arg0) { + delegate.onMediaMetadataChanged(arg0); + } + + @Override public void onPlaylistMetadataChanged(androidx.media3.common.MediaMetadata arg0) { + delegate.onPlaylistMetadataChanged(arg0); + } + + @Override public void onIsLoadingChanged(boolean arg0) { + delegate.onIsLoadingChanged(arg0); + } + + @Override public void onLoadingChanged(boolean arg0) { + delegate.onLoadingChanged(arg0); + } + + @Override public void onAvailableCommandsChanged(androidx.media3.common.Player.Commands arg0) { + delegate.onAvailableCommandsChanged(arg0); + } + + @Override public void onTrackSelectionParametersChanged(androidx.media3.common.TrackSelectionParameters arg0) { + delegate.onTrackSelectionParametersChanged(arg0); + } + + @Override public void onPlayerStateChanged(boolean arg0, int arg1) { + delegate.onPlayerStateChanged(arg0, arg1); + } + + @Override public void onPlaybackStateChanged(int arg0) { + delegate.onPlaybackStateChanged(arg0); + } + + @Override public void onPlayWhenReadyChanged(boolean arg0, int arg1) { + delegate.onPlayWhenReadyChanged(arg0, arg1); + } + + @Override public void onPlaybackSuppressionReasonChanged(int arg0) { + delegate.onPlaybackSuppressionReasonChanged(arg0); + } + + @Override public void onIsPlayingChanged(boolean arg0) { + delegate.onIsPlayingChanged(arg0); + } + + @Override public void onRepeatModeChanged(int arg0) { + delegate.onRepeatModeChanged(arg0); + } + + @Override public void onShuffleModeEnabledChanged(boolean arg0) { + delegate.onShuffleModeEnabledChanged(arg0); + } + + @Override public void onPlayerError(androidx.media3.common.PlaybackException arg0) { + delegate.onPlayerError(arg0); + } + + @Override public void onPlayerErrorChanged(androidx.media3.common.PlaybackException arg0) { + delegate.onPlayerErrorChanged(arg0); + } + + @Override public void onPositionDiscontinuity(int arg0) { + delegate.onPositionDiscontinuity(arg0); + } + + @Override public void onPositionDiscontinuity(androidx.media3.common.Player.PositionInfo arg0, androidx.media3.common.Player.PositionInfo arg1, int arg2) { + delegate.onPositionDiscontinuity(arg0, arg1, arg2); + } + + @Override public void onPlaybackParametersChanged(androidx.media3.common.PlaybackParameters arg0) { + delegate.onPlaybackParametersChanged(arg0); + } + + @Override public void onSeekBackIncrementChanged(long arg0) { + delegate.onSeekBackIncrementChanged(arg0); + } + + @Override public void onSeekForwardIncrementChanged(long arg0) { + delegate.onSeekForwardIncrementChanged(arg0); + } + + @Override public void onMaxSeekToPreviousPositionChanged(long arg0) { + delegate.onMaxSeekToPreviousPositionChanged(arg0); + } + + @Override public void onAudioSessionIdChanged(int arg0) { + delegate.onAudioSessionIdChanged(arg0); + } + + @Override public void onAudioAttributesChanged(androidx.media3.common.AudioAttributes arg0) { + delegate.onAudioAttributesChanged(arg0); + } + + @Override public void onVolumeChanged(float arg0) { + delegate.onVolumeChanged(arg0); + } + + @Override public void onSkipSilenceEnabledChanged(boolean arg0) { + delegate.onSkipSilenceEnabledChanged(arg0); + } + + @Override public void onDeviceInfoChanged(androidx.media3.common.DeviceInfo arg0) { + delegate.onDeviceInfoChanged(arg0); + } + + @Override public void onDeviceVolumeChanged(int arg0, boolean arg1) { + delegate.onDeviceVolumeChanged(arg0, arg1); + } + + @Override public void onVideoSizeChanged(androidx.media3.common.VideoSize arg0) { + delegate.onVideoSizeChanged(arg0); + } + + @Override public void onSurfaceSizeChanged(int arg0, int arg1) { + delegate.onSurfaceSizeChanged(arg0, arg1); + } + + @Override public void onRenderedFirstFrame() { + delegate.onRenderedFirstFrame(); + } + + @Override public void onCues(java.util.List arg0) { + delegate.onCues(arg0); + } + + @Override public void onCues(androidx.media3.common.text.CueGroup arg0) { + delegate.onCues(arg0); + } + + @Override public void onMetadata(androidx.media3.common.Metadata arg0) { + delegate.onMetadata(arg0); + } +} diff --git a/app/src/main/java/com/sparktube/app/playback/QueueNavigationPolicy.kt b/app/src/main/java/com/sparktube/app/playback/QueueNavigationPolicy.kt new file mode 100644 index 0000000..2b79080 --- /dev/null +++ b/app/src/main/java/com/sparktube/app/playback/QueueNavigationPolicy.kt @@ -0,0 +1,8 @@ +package com.sparktube.app.playback + +/** Video app-queue boundaries, independent of Android and stream resolution. */ +internal object QueueNavigationPolicy { + fun hasCurrent(index: Int, size: Int): Boolean = size > 0 && index >= 0 && index < size + fun hasNext(index: Int, size: Int): Boolean = hasCurrent(index, size) && index < size - 1 + fun hasPrevious(index: Int, size: Int): Boolean = hasCurrent(index, size) && index > 0 +} diff --git a/app/src/main/java/com/sparktube/app/playback/SleepTimerState.kt b/app/src/main/java/com/sparktube/app/playback/SleepTimerState.kt new file mode 100644 index 0000000..7196101 --- /dev/null +++ b/app/src/main/java/com/sparktube/app/playback/SleepTimerState.kt @@ -0,0 +1,57 @@ +package com.sparktube.app.playback + +/** Pure state for playback sleep timers. Time values are monotonic milliseconds supplied by the caller. */ +class SleepTimerState { + enum class Mode { OFF, DURATION, END_OF_ITEM } + + var mode: Mode = Mode.OFF + private set + + /** Monotonic deadline for [Mode.DURATION], otherwise null. */ + var deadlineMs: Long? = null + private set + + private var endOfItemMediaId: String? = null + + /** Starts or replaces a duration timer. Valid minutes are positive Int values. */ + fun setDuration(minutes: Int, nowMs: Long) { + require(minutes > 0) { "minutes must be positive" } + val durationMs = minutes.toLong() * MILLIS_PER_MINUTE + // Saturation is defensive for unusual caller-supplied clocks; normal elapsedRealtime values fit. + deadlineMs = if (nowMs > Long.MAX_VALUE - durationMs) Long.MAX_VALUE else nowMs + durationMs + mode = Mode.DURATION + endOfItemMediaId = null + } + + /** Stops at the end of the item whose ID is captured now (including automatic repeat). */ + fun setEndOfItem(mediaId: String?) { + mode = Mode.END_OF_ITEM + deadlineMs = null + endOfItemMediaId = mediaId + } + + fun clear() { + mode = Mode.OFF + deadlineMs = null + endOfItemMediaId = null + } + + /** Remaining duration, clamped to zero; null unless a duration timer is active. */ + fun remainingMs(nowMs: Long): Long? = + if (mode == Mode.DURATION) ((deadlineMs!! - nowMs).coerceAtLeast(0L)) else null + + fun isExpired(nowMs: Long): Boolean = + mode == Mode.DURATION && nowMs >= deadlineMs!! + + /** True only when the item reaching its end is the item captured by END_OF_ITEM. */ + fun shouldStopAtEnd(mediaId: String?): Boolean = + mode == Mode.END_OF_ITEM && mediaId == endOfItemMediaId + + /** Stops before an automatic transition (including repeat) out of the captured item. */ + fun shouldStopOnTransition(previousMediaId: String?, automatic: Boolean): Boolean = + automatic && mode == Mode.END_OF_ITEM && previousMediaId == endOfItemMediaId + + private companion object { + const val MILLIS_PER_MINUTE = 60_000L + } +} diff --git a/app/src/main/java/com/sparktube/app/playback/VideoFramePolicy.kt b/app/src/main/java/com/sparktube/app/playback/VideoFramePolicy.kt new file mode 100644 index 0000000..a8d0a98 --- /dev/null +++ b/app/src/main/java/com/sparktube/app/playback/VideoFramePolicy.kt @@ -0,0 +1,16 @@ +package com.sparktube.app.playback + +/** Playback can be READY because of audio alone; only a rendered frame proves video output. */ +internal object VideoFramePolicy { + const val TIMEOUT_MS = 8_000L + enum class Action { WAIT, REBIND_OUTPUT, RETRY_SOURCE, SHOW_ERROR } + + fun action(visibleWaitMs: Long, firstFrameSeen: Boolean, recoveryStep: Int): Action { + if (firstFrameSeen || visibleWaitMs < TIMEOUT_MS) return Action.WAIT + return when (recoveryStep) { + 0 -> Action.REBIND_OUTPUT + 1 -> Action.RETRY_SOURCE + else -> Action.SHOW_ERROR + } + } +} diff --git a/app/src/main/java/com/sparktube/app/ui/MainActivity.kt b/app/src/main/java/com/sparktube/app/ui/MainActivity.kt index 554407e..c535c99 100644 --- a/app/src/main/java/com/sparktube/app/ui/MainActivity.kt +++ b/app/src/main/java/com/sparktube/app/ui/MainActivity.kt @@ -99,6 +99,19 @@ class MainActivity : AppCompatActivity() { } applySelection() + if (intent.getBooleanExtra(EXTRA_OPEN_DOWNLOADS, false)) showDownloads() + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + if (intent.getBooleanExtra(EXTRA_OPEN_DOWNLOADS, false)) showDownloads() + } + + private fun showDownloads() { + select(R.id.navLibrary) + supportFragmentManager.executePendingTransactions() + (fragments[R.id.navLibrary] as? LibraryFragment)?.showDownloadsTab() } override fun onSaveInstanceState(outState: Bundle) { @@ -278,6 +291,13 @@ class MainActivity : AppCompatActivity() { } companion object { + private const val EXTRA_OPEN_DOWNLOADS = "open_downloads" + fun openDownloads(context: android.content.Context) { + context.startActivity(Intent(context, MainActivity::class.java).apply { + putExtra(EXTRA_OPEN_DOWNLOADS, true) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + }) + } private const val STATE_SELECTED = "selected_nav" private const val REQ_NOTIFICATIONS = 4711 private val NAV_IDS = intArrayOf(R.id.navHome, R.id.navMusic, R.id.navLibrary, R.id.navMenu) diff --git a/app/src/main/java/com/sparktube/app/ui/common/DownloadAdapter.kt b/app/src/main/java/com/sparktube/app/ui/common/DownloadAdapter.kt index 830c09e..4cd8121 100644 --- a/app/src/main/java/com/sparktube/app/ui/common/DownloadAdapter.kt +++ b/app/src/main/java/com/sparktube/app/ui/common/DownloadAdapter.kt @@ -1,62 +1,141 @@ package com.sparktube.app.ui.common import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup -import androidx.core.content.ContextCompat +import androidx.appcompat.widget.PopupMenu import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView -import coil.load import com.sparktube.app.R import com.sparktube.app.data.DownloadRecord import com.sparktube.app.databinding.ItemDownloadBinding import com.sparktube.app.download.DownloadCenter +import com.sparktube.app.download.DownloadSharing import com.sparktube.app.util.Thumbs /** Download entries on the Library > Downloads tab. */ class DownloadAdapter( private val onClick: (DownloadRecord) -> Unit, - private val onLongClick: (DownloadRecord) -> Unit + private val onLongClick: (DownloadRecord) -> Unit, + private val onPause: ((DownloadRecord) -> Unit)? = null, + private val audioStyle: Boolean = false ) : ListAdapter(DIFF) { - companion object { private val DIFF = object : DiffUtil.ItemCallback() { override fun areItemsTheSame(a: DownloadRecord, b: DownloadRecord): Boolean = a.videoId == b.videoId && a.type == b.type && a.quality == b.quality - override fun areContentsTheSame(a: DownloadRecord, b: DownloadRecord): Boolean = - a.status == b.status && a.title == b.title + a.status == b.status && a.title == b.title && a.filePaths == b.filePaths } } class VH(val binding: ItemDownloadBinding) : RecyclerView.ViewHolder(binding.root) + /** Runtime metrics are not persisted in DownloadRecord or compared by DiffUtil. */ + fun refreshLiveMetrics() { notifyItemRangeChanged(0, itemCount) } + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH = VH(ItemDownloadBinding.inflate(LayoutInflater.from(parent.context), parent, false)) override fun onBindViewHolder(holder: VH, position: Int) { val item = getItem(position) val b = holder.binding - + val context = b.root.context + if (audioStyle) { + val density = context.resources.displayMetrics.density + val imageSize = (60 * density).toInt() + b.thumb.layoutParams = b.thumb.layoutParams.apply { + width = imageSize + height = imageSize + } + b.title.maxLines = 1 + b.title.textSize = 15f + b.meta.maxLines = 1 + b.root.setPadding((8 * density).toInt(), (8 * density).toInt(), (4 * density).toInt(), (8 * density).toInt()) + } b.title.text = item.title val typeLabel = when (item.type) { - DownloadCenter.TYPE_AUDIO -> b.root.context.getString(R.string.download_type_audio) - DownloadCenter.TYPE_AV -> b.root.context.getString(R.string.download_type_av) - else -> b.root.context.getString(R.string.download_type_video) + DownloadCenter.TYPE_AUDIO -> context.getString(R.string.download_type_audio) + DownloadCenter.TYPE_AV -> context.getString(R.string.download_type_av) + else -> context.getString(R.string.download_type_video) } val statusLabel = when (item.status) { - DownloadCenter.STATUS_DONE -> b.root.context.getString(R.string.download_status_done) - DownloadCenter.STATUS_RUNNING -> b.root.context.getString(R.string.download_status_running) - DownloadCenter.STATUS_FAILED -> b.root.context.getString(R.string.download_status_failed) - else -> b.root.context.getString(R.string.download_status_pending) + DownloadCenter.STATUS_DONE -> context.getString(R.string.download_status_done) + DownloadCenter.STATUS_RUNNING -> context.getString(R.string.download_status_running) + DownloadCenter.STATUS_FAILED -> context.getString(R.string.download_status_failed) + DownloadCenter.STATUS_PAUSED -> context.getString(R.string.download_paused) + else -> context.getString(R.string.download_status_pending) + } + b.meta.text = if (audioStyle) item.uploader.ifBlank { typeLabel } + else "$typeLabel • ${item.quality} • $statusLabel" + val active = item.status == DownloadCenter.STATUS_PENDING || + item.status == DownloadCenter.STATUS_RUNNING || item.status == DownloadCenter.STATUS_PAUSED + val paused = item.status == DownloadCenter.STATUS_PAUSED + b.pauseButton.visibility = if (active) View.VISIBLE else View.GONE + b.pauseButton.text = context.getString(if (paused) R.string.download_resume else R.string.download_pause) + b.pauseButton.setOnClickListener { + onPause?.invoke(item) ?: DownloadCenter.togglePause(context, item) } - b.meta.text = "$typeLabel • ${item.quality} • $statusLabel" - Thumbs.load(b.thumb, item.thumbnailUrl) - b.root.setOnClickListener { if (item.status == DownloadCenter.STATUS_DONE) onClick(item) } - b.root.setOnLongClickListener { - onLongClick(item) - true + // Exactly one snapshot per bind; getters must not change the sampling period. + val metrics = DownloadCenter.metrics(item) + val total = metrics.totalBytes + val downloaded = DownloadMetricFormat.megabytes(metrics.downloadedBytes) + val sizeText = context.getString(R.string.download_size_progress, downloaded, + total?.let(DownloadMetricFormat::megabytes) ?: context.getString(R.string.download_total_unknown)) + b.progress.visibility = if (active) View.VISIBLE else View.GONE + b.progress.isIndeterminate = active && !paused && total == null + b.progress.progress = if (total != null && total > 0L) { + (metrics.downloadedBytes.toDouble() / total.toDouble() * 100.0).toInt().coerceIn(0, 100) + } else 0 + b.speed.text = when { + paused -> "$sizeText\n${context.getString(R.string.download_paused)}" + active -> { + val eta = metrics.remainingSeconds + val timeText = when { + total == null -> context.getString(R.string.download_time_unknown) + eta != null -> context.getString(R.string.download_remaining, DownloadMetricFormat.duration(eta)) + metrics.bytesPerSecond <= 0.0 && metrics.downloadedBytes > 0L -> context.getString(R.string.download_waiting) + else -> context.getString(R.string.download_estimating) + } + "$sizeText\n${DownloadMetricFormat.rate(metrics.bytesPerSecond)} · $timeText" + } + item.status == DownloadCenter.STATUS_DONE -> downloaded + else -> "" + } + if (audioStyle) { + b.speed.text = when { + active -> { + val percent = if (total != null && total > 0L) "${metrics.progressPercent}% · " else "" + "$percent${b.speed.text}\n${item.quality} • $statusLabel" + } + item.status == DownloadCenter.STATUS_DONE -> listOfNotNull( + item.quality.takeIf { it.isNotBlank() }, + downloaded + ).joinToString(" • ") + else -> statusLabel + } } + b.overflowButton.visibility = if (item.status == DownloadCenter.STATUS_DONE) View.VISIBLE else View.GONE + b.overflowButton.setOnClickListener { anchor -> + PopupMenu(context, anchor).apply { + menu.add(0, 1, 0, R.string.download_open) + menu.add(0, 2, 1, R.string.download_share) + menu.add(0, 3, 2, R.string.download_delete) + setOnMenuItemClickListener { action -> + when (action.itemId) { + 1 -> onClick(item) + 2 -> DownloadSharing.share(context, item) + 3 -> onLongClick(item) // Preserve Library's existing delete confirmation. + } + true + } + show() + } + } + Thumbs.load(b.thumb, item.thumbnailUrl) + b.root.setOnClickListener { if (item.status == DownloadCenter.STATUS_DONE) onClick(item) } + b.root.setOnLongClickListener { onLongClick(item); true } } } diff --git a/app/src/main/java/com/sparktube/app/ui/common/DownloadMetricFormat.kt b/app/src/main/java/com/sparktube/app/ui/common/DownloadMetricFormat.kt new file mode 100644 index 0000000..7e03cb5 --- /dev/null +++ b/app/src/main/java/com/sparktube/app/ui/common/DownloadMetricFormat.kt @@ -0,0 +1,30 @@ +package com.sparktube.app.ui.common + +import java.util.Locale + +/** Decimal MB (1,000,000 bytes), never megabits or mislabeled binary MiB. */ +internal object DownloadMetricFormat { + fun megabytes(bytes: Long): String { + val value = bytes.coerceAtLeast(0L) + if (value in 1L..4_999L) return "<0.01 MB" + return String.format(Locale.US, "%.2f MB", value.toDouble() / 1_000_000.0) + } + + fun rate(bytesPerSecond: Double): String { + val value = bytesPerSecond.takeIf { it.isFinite() && it > 0.0 } ?: 0.0 + if (value > 0.0 && value < 5_000.0) return "<0.01 MB/s" + return String.format(Locale.US, "%.2f MB/s", value / 1_000_000.0) + } + + fun duration(seconds: Long): String { + val value = seconds.coerceAtLeast(0L) + val hours = value / 3_600L + val minutes = value % 3_600L / 60L + val remainder = value % 60L + return when { + hours > 0 -> "$hours h $minutes min" + minutes > 0 -> "$minutes min $remainder s" + else -> "$remainder s" + } + } +} diff --git a/app/src/main/java/com/sparktube/app/ui/common/VideoAdapter.kt b/app/src/main/java/com/sparktube/app/ui/common/VideoAdapter.kt index 78334e6..52a5125 100644 --- a/app/src/main/java/com/sparktube/app/ui/common/VideoAdapter.kt +++ b/app/src/main/java/com/sparktube/app/ui/common/VideoAdapter.kt @@ -1,19 +1,22 @@ package com.sparktube.app.ui.common +import android.content.Intent import android.view.LayoutInflater import android.view.ViewGroup -import androidx.core.content.ContextCompat +import android.widget.Toast +import androidx.appcompat.widget.PopupMenu import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView -import coil.load import com.sparktube.app.R +import com.sparktube.app.data.LocalStore import com.sparktube.app.databinding.ItemVideoBinding import com.sparktube.app.util.Thumbs class VideoAdapter( private val onClick: (VideoUiModel) -> Unit, - private val onLongClick: ((VideoUiModel) -> Boolean)? = null + private val onLongClick: ((VideoUiModel) -> Boolean)? = null, + private val onDownload: ((VideoUiModel) -> Unit)? = null ) : ListAdapter(DIFF) { companion object { @@ -49,6 +52,43 @@ class VideoAdapter( Thumbs.load(b.thumbnail, item.thumbnailUrl) Thumbs.load(b.avatar, item.uploaderAvatarUrl) + b.overflowButton.contentDescription = b.root.context.getString(R.string.video_more_options, item.title) + b.overflowButton.setOnClickListener { anchor -> + val context = anchor.context + PopupMenu(context, anchor).apply { + menu.add(0, 1, 0, R.string.cd_play) + menu.add(0, 5, 1, R.string.action_download) + // Library/playlist screens own removal and refresh of their lists. + // Expose those existing callbacks only after a deliberate menu choice. + if (onLongClick != null) { + menu.add(0, 4, 4, R.string.video_remove_from_list) + } else { + menu.add(0, 2, 2, if (LocalStore.isFavorite(context, item.url)) + R.string.video_remove_favorite else R.string.action_favorite) + } + menu.add(0, 3, 3, R.string.action_share) + setOnMenuItemClickListener { action -> + when (action.itemId) { + 1 -> onClick(item) + 5 -> if (onDownload != null) onDownload.invoke(item) + else VideoDownloadAction.start(context, item) + 2 -> { + val added = LocalStore.toggleFavorite(context, item.toEntry()) + Toast.makeText(context, if (added) R.string.added_to_favorites + else R.string.removed_from_favorites, Toast.LENGTH_SHORT).show() + } + 3 -> context.startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, item.url) + }, context.getString(R.string.share_via))) + 4 -> onLongClick?.invoke(item) + else -> return@setOnMenuItemClickListener false + } + true + } + show() + } + } b.root.setOnClickListener { onClick(item) } b.root.setOnLongClickListener { onLongClick?.invoke(item) ?: false diff --git a/app/src/main/java/com/sparktube/app/ui/common/VideoDownloadAction.kt b/app/src/main/java/com/sparktube/app/ui/common/VideoDownloadAction.kt new file mode 100644 index 0000000..c01e980 --- /dev/null +++ b/app/src/main/java/com/sparktube/app/ui/common/VideoDownloadAction.kt @@ -0,0 +1,61 @@ +package com.sparktube.app.ui.common + +import android.content.Context +import android.content.ContextWrapper +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.lifecycle.lifecycleScope +import com.sparktube.app.R +import com.sparktube.app.data.YtRepository +import com.sparktube.app.download.DownloadCenter +import com.sparktube.app.playback.StreamCatalog +import com.sparktube.app.ui.MainActivity +import com.sparktube.app.ui.player.DownloadSheet +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch +import java.util.WeakHashMap + +/** Starts the existing download picker without changing current playback. */ +internal object VideoDownloadAction { + // Accessed only on the UI thread. Do not retain a finished screen globally. + private val resolving = WeakHashMap>() + + fun start(context: Context, item: VideoUiModel) { + if (DownloadCenter.isDownloaded(context, item.url) || + DownloadCenter.hasActiveDownload(context, item.url)) { + MainActivity.openDownloads(context) + return + } + val activity = activityFrom(context) ?: return + if (activity.isFinishing || activity.isDestroyed) return + val urls = resolving.getOrPut(activity) { mutableSetOf() } + if (!urls.add(item.url)) return + val appContext = context.applicationContext + val entry = item.toQueueEntry() + activity.lifecycleScope.launch { + try { + val catalog = StreamCatalog(YtRepository.streamInfo(item.url)) + if (!activity.isFinishing && !activity.isDestroyed) { + DownloadSheet(activity, entry, catalog).show() + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + Toast.makeText(appContext, R.string.error_no_streams, Toast.LENGTH_SHORT).show() + } finally { + urls.remove(item.url) + if (urls.isEmpty()) resolving.remove(activity) + } + } + } + + private fun activityFrom(context: Context): ComponentActivity? { + var current = context + while (true) { + if (current is ComponentActivity) return current + val base = (current as? ContextWrapper)?.baseContext ?: return null + if (base === current) return null + current = base + } + } +} diff --git a/app/src/main/java/com/sparktube/app/ui/home/HomeFeedCache.kt b/app/src/main/java/com/sparktube/app/ui/home/HomeFeedCache.kt new file mode 100644 index 0000000..3d0ab99 --- /dev/null +++ b/app/src/main/java/com/sparktube/app/ui/home/HomeFeedCache.kt @@ -0,0 +1,104 @@ +package com.sparktube.app.ui.home + +import android.content.Context +import com.sparktube.app.ui.common.VideoUiModel +import org.json.JSONArray +import org.json.JSONObject +import java.io.File + +/** Cache policy and storage for display-only Home cards (watch-page URLs, never resolved stream URLs). */ +internal object HomeFeedCache { + const val MAX_ENTRIES = 60 + const val TTL_MILLIS = 3L * 24 * 60 * 60 * 1000 + private const val VERSION = 1 + private val memory = mutableMapOf() + + data class Snapshot(val savedAtMillis: Long, val items: List) + + /** Pure policy: only a nonempty, recent snapshot is eligible for instant display. */ + fun isUsable(savedAtMillis: Long, nowMillis: Long): Boolean = + savedAtMillis > 0L && nowMillis >= savedAtMillis && nowMillis - savedAtMillis <= TTL_MILLIS + + /** Bounds entries and rejects blank/non-video destinations; URLs remain page URLs from VideoUiModel. */ + fun bounded(items: List): List = items + .asSequence() + .filter { it.url.isNotBlank() && it.title.isNotBlank() } + .distinctBy { it.url } + .take(MAX_ENTRIES) + .toList() + + @Synchronized + fun read(context: Context, country: String, nowMillis: Long = System.currentTimeMillis()): List { + val mem = memory[country] + if (mem != null && isUsable(mem.savedAtMillis, nowMillis) && mem.items.isNotEmpty()) return mem.items + val file = cacheFile(context, country) + val snapshot = runCatching { + val root = JSONObject(file.readText()) + if (root.optInt("version") != VERSION || root.optString("country") != country) return@runCatching null + val saved = root.optLong("savedAt", 0L) + if (!isUsable(saved, nowMillis)) return@runCatching null + val arr = root.optJSONArray("items") ?: return@runCatching null + val result = ArrayList(arr.length().coerceAtMost(MAX_ENTRIES)) + for (i in 0 until arr.length().coerceAtMost(MAX_ENTRIES)) { + val row = arr.optJSONObject(i) ?: continue + result += VideoUiModel( + url = row.optString("url"), title = row.optString("title"), + uploader = row.optString("uploader"), thumbnailUrl = row.optString("thumbnail"), + durationSec = row.optLong("duration"), viewCount = row.optLong("views", -1L), + uploadDate = row.optString("date"), uploaderAvatarUrl = row.optString("avatar") + ) + } + Snapshot(saved, bounded(result)).takeIf { it.items.isNotEmpty() } + }.getOrNull() + if (snapshot == null) { + memory.remove(country) + runCatching { file.delete() } + return emptyList() + } + memory[country] = snapshot + return snapshot.items + } + + @Synchronized + fun write( + context: Context, + country: String, + items: List, + savedAtMillis: Long = System.currentTimeMillis(), + shouldWrite: () -> Boolean = { true } + ) { + // Recheck after acquiring the cache lock, not before waiting behind another writer. + if (!shouldWrite()) return + val safeItems = bounded(items) + if (safeItems.isEmpty() || country.isBlank() || savedAtMillis <= 0L) return + val rows = JSONArray() + safeItems.forEach { model -> + rows.put(JSONObject().put("url", model.url).put("title", model.title) + .put("uploader", model.uploader).put("thumbnail", model.thumbnailUrl) + .put("duration", model.durationSec).put("views", model.viewCount) + .put("date", model.uploadDate).put("avatar", model.uploaderAvatarUrl)) + } + val snapshot = Snapshot(savedAtMillis, safeItems) + memory[country] = snapshot + val file = cacheFile(context, country) + runCatching { + file.parentFile?.mkdirs() + val payload = JSONObject().put("version", VERSION).put("country", country) + .put("savedAt", savedAtMillis).put("items", rows).toString() + val temp = File(file.parentFile, file.name + ".tmp") + temp.writeText(payload) + if (!temp.renameTo(file)) { + file.writeText(payload) + temp.delete() + } + } + } + + @Synchronized + internal fun clearMemoryForTests() = memory.clear() + + private fun cacheFile(context: Context, country: String): File { + val key = country.uppercase().filter { it.isLetterOrDigit() }.take(8).ifBlank { "default" } + return File(File(context.applicationContext.filesDir, "home-feed"), "$key.json") + } +} diff --git a/app/src/main/java/com/sparktube/app/ui/home/HomeFragment.kt b/app/src/main/java/com/sparktube/app/ui/home/HomeFragment.kt index 6a59494..260b48e 100644 --- a/app/src/main/java/com/sparktube/app/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/sparktube/app/ui/home/HomeFragment.kt @@ -10,7 +10,6 @@ import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager -import com.sparktube.app.BuildConfig import com.sparktube.app.R import com.sparktube.app.data.Countries import com.sparktube.app.data.RecommendEngine @@ -39,6 +38,8 @@ class HomeFragment : Fragment() { private lateinit var adapter: VideoAdapter private var loadedCountry: String? = null private var loadJob: kotlinx.coroutines.Job? = null + @Volatile private var feedGeneration = 0L + private var visibleCountry: String? = null /** * Elapsed-realtime stamp of the last successful feed load. The feed now @@ -76,23 +77,6 @@ class HomeFragment : Fragment() { ) } - // Test-crash button: verifies the local crash reporter pipeline - // (CrashReporter writes reports into the "SparkTube" folder on the - // device — Download/SparkTube on Android 10+). - // DEBUG BUILDS ONLY — release users never see it. - // Tap → uncaught exception → report file written to the device → - // reopen the app and check Settings → Crash logs to view/share it. - binding.crashTestButton.isVisible = BuildConfig.DEBUG - binding.crashTestButton.setOnClickListener { - CrashReporter.logBreadcrumb("Test crash button tapped on Home") - CrashReporter.logBreadcrumb( - "loaded_country=${loadedCountry ?: ""}" - ) - throw RuntimeException( - "SparkTube test crash — local crash report check" - ) - } - binding.retryButton.setOnClickListener { load() } // One automatic refresh every 5 minutes while the feed is on screen. @@ -141,8 +125,7 @@ class HomeFragment : Fragment() { private fun load() { val country = AppPrefs.countryOrDefault loadedCountry = country - lastLoadedAt = SystemClock.elapsedRealtime() - + val generation = ++feedGeneration loadJob?.cancel() // Capture the binding: a theme change relaunches the activity, which // destroys this view (nulling _binding) while the coroutine may still @@ -154,18 +137,55 @@ class HomeFragment : Fragment() { loadJob = viewLifecycleOwner.lifecycleScope.launch { b.errorView.isVisible = false b.emptyView.isVisible = false - // Skeleton cards instead of a spinner: the feed keeps its shape - // while the personalized list is being fetched. - showSkeleton(b.list, SkeletonAdapter.STYLE_VIDEO, count = 10) + b.list.adapter = adapter + if (visibleCountry != null && visibleCountry != country) { + adapter.submitList(emptyList()) + visibleCountry = null + } + b.swipe.isRefreshing = visibleCountry == country && adapter.currentList.isNotEmpty() try { + // Disk access is off-main. A cached list is installed before any network work. + val cached = withContext(Dispatchers.IO) { HomeFeedCache.read(ctx, country) } + if (!isCurrent(b, country, generation)) return@launch + if (cached.isNotEmpty()) { + adapter.submitList(cached) + visibleCountry = country + } else if (visibleCountry != country || adapter.currentList.isEmpty()) { + showSkeleton(b.list, SkeletonAdapter.STYLE_VIDEO, count = 10) + } val snap = withContext(Dispatchers.IO) { RecommendEngine.snapshot(ctx) } - val items = YtRepository.personalizedFeed(country, snap) + val items = YtRepository.personalizedFeed(country, snap, onInitialItems = { initial -> + val preview = initial.map { it.toUiModel() } + withContext(Dispatchers.Main) { + if (preview.isNotEmpty() && (visibleCountry != country || adapter.currentList.isEmpty()) && isCurrent(b, country, generation)) { + b.list.adapter = adapter + adapter.submitList(preview) + visibleCountry = country + } + } + }) + if (!isCurrent(b, country, generation)) return@launch + val models = items.map { it.toUiModel() } b.list.adapter = adapter - adapter.submitList(items.map { it.toUiModel() }) - b.emptyView.isVisible = items.isEmpty() - if (items.isEmpty()) { + if (models.isNotEmpty()) { + adapter.submitList(models) + visibleCountry = country + lastLoadedAt = SystemClock.elapsedRealtime() + withContext(Dispatchers.IO) { + if (isActive && generation == feedGeneration && AppPrefs.countryOrDefault == country) { + HomeFeedCache.write(ctx, country, models, shouldWrite = { + generation == feedGeneration && AppPrefs.countryOrDefault == country + }) + } + } + } else if (adapter.currentList.isNotEmpty()) { + // An empty network result must not erase a useful saved feed. + b.emptyView.isVisible = false + } + b.emptyView.isVisible = items.isEmpty() && adapter.currentList.isEmpty() + if (items.isEmpty() && adapter.currentList.isEmpty()) { b.errorText.text = getString(R.string.error_no_results) b.errorView.isVisible = true } @@ -173,20 +193,30 @@ class HomeFragment : Fragment() { // A newer load() cancelled this one: not an error. throw e } catch (e: Exception) { + if (!isCurrent(b, country, generation)) return@launch b.list.adapter = adapter - adapter.submitList(emptyList()) - b.errorText.text = Formatters.friendlyException(e) - b.errorView.isVisible = true + if (adapter.currentList.isEmpty()) { + b.errorText.text = Formatters.friendlyException(e) + b.errorView.isVisible = true + } // Mark as "never loaded" so the next return to this screen // retries automatically instead of waiting out the 5 min. lastLoadedAt = 0L } finally { - b.swipe.isRefreshing = false + if (_binding === b && generation == feedGeneration) b.swipe.isRefreshing = false } } } + private fun isCurrent(b: FragmentHomeBinding, country: String, generation: Long): Boolean = + _binding === b && isAdded && generation == feedGeneration && AppPrefs.countryOrDefault == country + override fun onDestroyView() { + feedGeneration++ + loadJob?.cancel() + loadedCountry = null + visibleCountry = null + lastLoadedAt = 0L super.onDestroyView() _binding = null } diff --git a/app/src/main/java/com/sparktube/app/ui/library/LibraryFragment.kt b/app/src/main/java/com/sparktube/app/ui/library/LibraryFragment.kt index 142ff9c..ef77c88 100644 --- a/app/src/main/java/com/sparktube/app/ui/library/LibraryFragment.kt +++ b/app/src/main/java/com/sparktube/app/ui/library/LibraryFragment.kt @@ -4,17 +4,22 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.MotionEvent +import android.os.Handler +import android.os.Looper import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import com.google.android.material.tabs.TabLayout import com.sparktube.app.R import com.sparktube.app.data.LocalStore import com.sparktube.app.databinding.FragmentLibraryBinding import com.sparktube.app.download.DownloadCenter import com.sparktube.app.playback.PlaybackCenter +import com.sparktube.app.playback.QueueEntry import com.sparktube.app.ui.channel.ChannelActivity import com.sparktube.app.ui.common.ChannelRowAdapter import com.sparktube.app.ui.common.DownloadAdapter @@ -24,6 +29,8 @@ import com.sparktube.app.ui.common.toEntry import com.sparktube.app.ui.common.toUiModel import com.sparktube.app.ui.player.PlayerActivity import com.sparktube.app.ui.playlist.PlaylistActivity +import com.sparktube.app.ui.music.AudioDownloadLibrary +import com.sparktube.app.ui.music.NowPlayingActivity /** * Library: History, Favorites, Subscriptions, Downloads and Playlists tabs. @@ -40,6 +47,12 @@ class LibraryFragment : Fragment() { private lateinit var playlistAdapter: PlaylistAdapter private var tab = TAB_HISTORY + private val uiHandler = Handler(Looper.getMainLooper()) + private val refreshDownloads = object : Runnable { + override fun run() { if (_binding != null && tab == TAB_DOWNLOADS) { refresh(); uiHandler.postDelayed(this, 1000) } } + } + private var downX = 0f + private var downY = 0f override fun onCreateView( inflater: LayoutInflater, @@ -78,8 +91,15 @@ class LibraryFragment : Fragment() { } ) downloadAdapter = DownloadAdapter( - onClick = { record -> PlaybackCenter.playDownload(record) }, - onLongClick = { record -> confirmDelete(record) } + onClick = { record -> openDownload(record) }, + onLongClick = { record -> confirmDelete(record) }, + onPause = { record -> + if (record.status != DownloadCenter.STATUS_PAUSED) AlertDialog.Builder(requireContext()) + .setMessage(R.string.download_process_limit).setPositiveButton(android.R.string.ok) { _, _ -> + DownloadCenter.togglePause(requireContext(), record); refresh() + }.setNegativeButton(android.R.string.cancel, null).show() + else { DownloadCenter.togglePause(requireContext(), record); refresh() } + } ) playlistAdapter = PlaylistAdapter( onClick = { playlist -> @@ -94,7 +114,9 @@ class LibraryFragment : Fragment() { binding.tabs.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { override fun onTabSelected(tab: TabLayout.Tab) { this@LibraryFragment.tab = tab.position + uiHandler.removeCallbacks(refreshDownloads) refresh() + if (tab.position == TAB_DOWNLOADS) uiHandler.post(refreshDownloads) } override fun onTabUnselected(tab: TabLayout.Tab) = Unit @@ -102,16 +124,53 @@ class LibraryFragment : Fragment() { }) binding.tabs.selectTab(binding.tabs.getTabAt(tab)) + binding.list.addOnItemTouchListener(object : RecyclerView.SimpleOnItemTouchListener() { + private var switched = false + override fun onInterceptTouchEvent(rv: RecyclerView, event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { downX = event.x; downY = event.y; switched = false } + MotionEvent.ACTION_MOVE -> { + val dx = event.x - downX + val dy = event.y - downY + val threshold = 56 * resources.displayMetrics.density + if (!switched && kotlin.math.abs(dx) > threshold && kotlin.math.abs(dx) > kotlin.math.abs(dy) * 1.4f) { + switched = true + val direction = if (rv.layoutDirection == View.LAYOUT_DIRECTION_RTL) -1 else 1 + val next = (this@LibraryFragment.tab + (if (dx < 0) direction else -direction)).coerceIn(TAB_HISTORY, TAB_PLAYLISTS) + binding.tabs.getTabAt(next)?.select() + return true + } + } + } + return false + } + }) + } + + fun showDownloadsTab() { + if (_binding == null) return + binding.tabs.getTabAt(TAB_DOWNLOADS)?.select() + refresh() + uiHandler.removeCallbacks(refreshDownloads) + uiHandler.post(refreshDownloads) } override fun onResume() { super.onResume() refresh() + uiHandler.removeCallbacks(refreshDownloads) + if (tab == TAB_DOWNLOADS) uiHandler.post(refreshDownloads) } + override fun onPause() { uiHandler.removeCallbacks(refreshDownloads); super.onPause() } + override fun onHiddenChanged(hidden: Boolean) { super.onHiddenChanged(hidden) - if (!hidden) refresh() + uiHandler.removeCallbacks(refreshDownloads) + if (!hidden) { + refresh() + if (tab == TAB_DOWNLOADS) uiHandler.post(refreshDownloads) + } } private fun refresh() { @@ -144,14 +203,39 @@ class LibraryFragment : Fragment() { private fun bindDownloads() { val context = context ?: return DownloadCenter.refreshStatuses(context) - // Music downloads live on the Music page, not here. - val downloads = LocalStore.videoDownloads(context) - binding.list.adapter = downloadAdapter - downloadAdapter.submitList(downloads) + // Include audio too so every transfer can be paused, resumed and shared here. + val downloads = LocalStore.downloads(context) + if (binding.list.adapter !== downloadAdapter) binding.list.adapter = downloadAdapter + downloadAdapter.submitList(downloads) { downloadAdapter.refreshLiveMetrics() } binding.emptyView.setText(R.string.empty_downloads) binding.emptyView.isVisible = downloads.isEmpty() } + private fun openDownload(record: com.sparktube.app.data.DownloadRecord) { + if (record.type != DownloadCenter.TYPE_AUDIO) { + PlaybackCenter.playDownload(record) + return + } + val playable = AudioDownloadLibrary.playable(listOf(record)).firstOrNull() + if (playable == null) { + Toast.makeText(requireContext(), R.string.download_file_missing, Toast.LENGTH_SHORT).show() + return + } + val (audio, path) = playable + PlaybackCenter.playMusicQueue( + listOf(QueueEntry( + url = "file://$path", + title = audio.title, + uploader = audio.uploader, + thumbnailUrl = audio.thumbnailUrl, + durationSec = 0L, + isMusic = true + )), + 0 + ) + NowPlayingActivity.start(requireContext()) + } + private fun bindPlaylists() { val context = context ?: return val playlists = LocalStore.playlists(context) @@ -188,6 +272,7 @@ class LibraryFragment : Fragment() { } override fun onDestroyView() { + uiHandler.removeCallbacks(refreshDownloads) super.onDestroyView() _binding = null } diff --git a/app/src/main/java/com/sparktube/app/ui/music/AudioDownloadLibrary.kt b/app/src/main/java/com/sparktube/app/ui/music/AudioDownloadLibrary.kt new file mode 100644 index 0000000..dcbe56e --- /dev/null +++ b/app/src/main/java/com/sparktube/app/ui/music/AudioDownloadLibrary.kt @@ -0,0 +1,20 @@ +package com.sparktube.app.ui.music + +import com.sparktube.app.data.DownloadRecord +import com.sparktube.app.download.DownloadCenter +import java.io.File + +/** All audio transfers stay visible; only completed, present files enter the music queue. */ +internal object AudioDownloadLibrary { + fun rows(downloads: List): List = + downloads.filter { it.type == DownloadCenter.TYPE_AUDIO } + + fun playable( + downloads: List, + fileExists: (String) -> Boolean = { File(it).isFile } + ): List> = rows(downloads).mapNotNull { record -> + if (record.status != DownloadCenter.STATUS_DONE) return@mapNotNull null + val path = record.filePaths.firstOrNull(fileExists) ?: return@mapNotNull null + record to path + } +} diff --git a/app/src/main/java/com/sparktube/app/ui/music/MusicListActivity.kt b/app/src/main/java/com/sparktube/app/ui/music/MusicListActivity.kt index 76cf9af..5c002b8 100644 --- a/app/src/main/java/com/sparktube/app/ui/music/MusicListActivity.kt +++ b/app/src/main/java/com/sparktube/app/ui/music/MusicListActivity.kt @@ -3,35 +3,41 @@ package com.sparktube.app.ui.music import android.content.Context import android.content.Intent import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.core.view.isVisible import androidx.recyclerview.widget.LinearLayoutManager import com.sparktube.app.R +import com.sparktube.app.data.DownloadRecord import com.sparktube.app.data.LocalStore import com.sparktube.app.data.VideoEntry import com.sparktube.app.databinding.ActivityMusicListBinding import com.sparktube.app.download.DownloadCenter import com.sparktube.app.playback.PlaybackCenter import com.sparktube.app.playback.QueueEntry +import com.sparktube.app.ui.common.DownloadAdapter import com.sparktube.app.ui.common.MusicRowAdapter import com.sparktube.app.ui.common.VideoUiModel import com.sparktube.app.util.Themes -import java.io.File -/** - * The music page's own lists: Favorites (songs favorited from the player) - * and Downloads (audio files). Playing anything here queues exactly this - * list — favorites play favorites one by one, downloads play downloads. - */ +/** Music favorites and the audio download library, including live transfers. */ class MusicListActivity : AppCompatActivity() { private lateinit var binding: ActivityMusicListBinding private lateinit var adapter: MusicRowAdapter - + private lateinit var downloadAdapter: DownloadAdapter private var favourites = true private var entries: List = emptyList() + private val uiHandler = Handler(Looper.getMainLooper()) + private val refreshDownloads = object : Runnable { + override fun run() { + reload() + uiHandler.postDelayed(this, 1000L) + } + } override fun onCreate(savedInstanceState: Bundle?) { Themes.apply(this) @@ -43,62 +49,96 @@ class MusicListActivity : AppCompatActivity() { binding.title.text = getString(if (favourites) R.string.music_favorites else R.string.music_downloads) - adapter = MusicRowAdapter( - onClick = { model -> - val index = entries.indexOfFirst { it.url == model.url } - if (index >= 0) { - PlaybackCenter.playMusicQueue(entries, index) - NowPlayingActivity.start(this) + adapter = MusicRowAdapter(onClick = { model -> + val index = entries.indexOfFirst { it.url == model.url } + if (index >= 0) playQueue(index) + }) + downloadAdapter = DownloadAdapter( + onClick = { record -> openDownload(record) }, + onLongClick = { record -> confirmDelete(record) }, + onPause = { record -> + if (record.status != DownloadCenter.STATUS_PAUSED) { + AlertDialog.Builder(this) + .setMessage(R.string.download_process_limit) + .setPositiveButton(android.R.string.ok) { _, _ -> + DownloadCenter.togglePause(this, record) + reload() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } else { + DownloadCenter.togglePause(this, record) + reload() } }, - onLongClick = if (favourites) null else ({ model -> - val record = LocalStore.musicDownloads(this) - .firstOrNull { "file://" + it.filePaths.firstOrNull() == model.url } - if (record != null) { - confirmDelete(record) - } - }) + audioStyle = true ) binding.list.layoutManager = LinearLayoutManager(this) - binding.list.adapter = adapter - + binding.list.adapter = if (favourites) adapter else downloadAdapter + // Avoid a change animation on every one-second metrics refresh. + if (!favourites) binding.list.itemAnimator = null binding.backButton.setOnClickListener { finish() } binding.playAllButton.setOnClickListener { - if (entries.isNotEmpty()) { - PlaybackCenter.playMusicQueue(entries, 0) - NowPlayingActivity.start(this) - } + // Re-check disk and completion state at the instant Play all is tapped. + reload() + if (entries.isNotEmpty()) playQueue(0) } } override fun onResume() { super.onResume() - // A theme / accent change while we were in the background. Themes.recreateIfNeeded(this) + uiHandler.removeCallbacks(refreshDownloads) reload() + if (!favourites) uiHandler.postDelayed(refreshDownloads, 1000L) + } + + override fun onPause() { + uiHandler.removeCallbacks(refreshDownloads) + super.onPause() + } + + override fun onDestroy() { + uiHandler.removeCallbacks(refreshDownloads) + super.onDestroy() } private fun reload() { + val hasRows: Boolean if (favourites) { entries = LocalStore.musicFavorites(this).map { it.toQueueEntry() } + adapter.submitList(entries.map { it.toRowModel() }) binding.emptyView.setText(R.string.empty_music_favorites) + hasRows = entries.isNotEmpty() } else { DownloadCenter.refreshStatuses(this) - entries = LocalStore.musicDownloads(this) - .mapNotNull { record -> - val path = record.filePaths.firstOrNull { File(it).exists() } - ?: return@mapNotNull null - record.toQueueEntry(path) - } + val records = AudioDownloadLibrary.rows(LocalStore.downloads(this)) + entries = AudioDownloadLibrary.playable(records).map { (record, path) -> record.toQueueEntry(path) } + downloadAdapter.submitList(records) { downloadAdapter.refreshLiveMetrics() } binding.emptyView.setText(R.string.empty_music_downloads) + hasRows = records.isNotEmpty() } - adapter.submitList(entries.map { it.toRowModel() }) - binding.emptyView.isVisible = entries.isEmpty() - binding.summaryRow.isVisible = entries.isNotEmpty() + binding.emptyView.isVisible = !hasRows + binding.summaryRow.isVisible = hasRows binding.subtitle.text = getString(R.string.music_songs_fmt, entries.size) + binding.playAllButton.isEnabled = entries.isNotEmpty() + binding.playAllButton.alpha = if (entries.isNotEmpty()) 1f else 0.45f + } + + private fun playQueue(index: Int) { + PlaybackCenter.playMusicQueue(entries, index) + NowPlayingActivity.start(this) + } + + private fun openDownload(record: DownloadRecord) { + reload() + val path = AudioDownloadLibrary.playable(listOf(record)).firstOrNull()?.second + val index = entries.indexOfFirst { it.url == "file://$path" } + if (path != null && index >= 0) playQueue(index) + else Toast.makeText(this, R.string.download_file_missing, Toast.LENGTH_SHORT).show() } - private fun confirmDelete(record: com.sparktube.app.data.DownloadRecord) { + private fun confirmDelete(record: DownloadRecord) { AlertDialog.Builder(this) .setTitle(R.string.download_delete_confirm) .setMessage(record.title) @@ -124,29 +164,16 @@ class MusicListActivity : AppCompatActivity() { } private fun VideoEntry.toQueueEntry(): QueueEntry = QueueEntry( - url = url, - title = title, - uploader = uploader, - thumbnailUrl = thumbnailUrl, - durationSec = durationSec, - isMusic = true + url = url, title = title, uploader = uploader, thumbnailUrl = thumbnailUrl, + durationSec = durationSec, isMusic = true ) private fun QueueEntry.toRowModel(): VideoUiModel = VideoUiModel( - url = url, - title = title, - uploader = uploader, - thumbnailUrl = thumbnailUrl, - durationSec = durationSec, - viewCount = -1L, - uploadDate = "" + url = url, title = title, uploader = uploader, thumbnailUrl = thumbnailUrl, + durationSec = durationSec, viewCount = -1L, uploadDate = "" ) -private fun com.sparktube.app.data.DownloadRecord.toQueueEntry(path: String): QueueEntry = QueueEntry( - url = "file://$path", - title = title, - uploader = uploader, - thumbnailUrl = thumbnailUrl, - durationSec = 0L, - isMusic = true +private fun DownloadRecord.toQueueEntry(path: String): QueueEntry = QueueEntry( + url = "file://$path", title = title, uploader = uploader, thumbnailUrl = thumbnailUrl, + durationSec = 0L, isMusic = true ) diff --git a/app/src/main/java/com/sparktube/app/ui/music/NowPlayingActivity.kt b/app/src/main/java/com/sparktube/app/ui/music/NowPlayingActivity.kt index fbbef04..1adf7d6 100644 --- a/app/src/main/java/com/sparktube/app/ui/music/NowPlayingActivity.kt +++ b/app/src/main/java/com/sparktube/app/ui/music/NowPlayingActivity.kt @@ -14,6 +14,10 @@ import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.CancellationException +import com.sparktube.app.ui.player.PlayerActivity import coil.load import com.sparktube.app.R import com.sparktube.app.data.LocalStore @@ -55,11 +59,14 @@ class NowPlayingActivity : AppCompatActivity() { // Radio keeps adding songs while it plays: keep "Up next" current. override fun onQueueChanged() = bindUpNext() + override fun onSleepTimerChanged() = bindTimer() + override fun onSleepTimerExpired() { finish() } } private val progressRunnable = object : Runnable { override fun run() { updateProgress() + bindTimer() updateDownloadState() handler.postDelayed(this, 500) } @@ -83,6 +90,23 @@ class NowPlayingActivity : AppCompatActivity() { requestNotificationPermissionIfNeeded() binding.backButton.setOnClickListener { finish() } + binding.modeSwitch.setOnClickListener { + binding.modeSwitch.isEnabled = false + lifecycleScope.launch { + try { + val available = if (PlaybackCenter.mode == PlaybackCenter.Mode.VIDEO) { + PlaybackCenter.switchBackToVideo() + true + } else PlaybackCenter.switchMusicToVideo() + if (available) { + PlayerActivity.startResume(this@NowPlayingActivity) + finish() + } else Toast.makeText(this@NowPlayingActivity, R.string.switch_video_unavailable, Toast.LENGTH_SHORT).show() + } catch (e: CancellationException) { throw e } + catch (e: Exception) { Toast.makeText(this@NowPlayingActivity, e.message ?: getString(R.string.switch_video_unavailable), Toast.LENGTH_SHORT).show() } + finally { binding.modeSwitch.isEnabled = true } + } + } binding.playPauseButton.setOnClickListener { PlaybackCenter.togglePlayPause() } binding.prevButton.setOnClickListener { PlaybackCenter.previous() } binding.nextButton.setOnClickListener { PlaybackCenter.next() } @@ -97,6 +121,7 @@ class NowPlayingActivity : AppCompatActivity() { binding.radioButton.setOnClickListener { restartRadio() } binding.downloadButton.setOnClickListener { onDownloadClicked() } binding.queueButton.setOnClickListener { showQueue() } + binding.timerButton.setOnClickListener { SleepTimerSheet(this).show() } binding.upNextCard.setOnClickListener { showQueue() } // The artwork is always a square that fits the space the controls @@ -259,6 +284,11 @@ class NowPlayingActivity : AppCompatActivity() { } } + private fun bindTimer() { + binding.timerButton.text = SleepTimerSheet.buttonLabel(this) + binding.timerButton.contentDescription = SleepTimerSheet.accessibilityLabel(this) + } + private fun showQueue() { QueueSheet(this).show() } diff --git a/app/src/main/java/com/sparktube/app/ui/music/SleepTimerSheet.kt b/app/src/main/java/com/sparktube/app/ui/music/SleepTimerSheet.kt new file mode 100644 index 0000000..c55f282 --- /dev/null +++ b/app/src/main/java/com/sparktube/app/ui/music/SleepTimerSheet.kt @@ -0,0 +1,125 @@ +package com.sparktube.app.ui.music + +import android.content.Context +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.view.Gravity +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.sparktube.app.R +import com.sparktube.app.playback.PlaybackCenter +import com.sparktube.app.playback.SleepTimerState +import java.util.Locale + +/** Same selector for the music screen and the video action row. */ +class SleepTimerSheet(context: Context) : BottomSheetDialog(context) { + private fun dp(n: Int) = (n * context.resources.displayMetrics.density).toInt() + private val green = context.getColor(R.color.music_green) + private val textColor = context.getColor(R.color.on_surface) + private val root = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + setPadding(0, dp(10), 0, dp(16)) + background = GradientDrawable().apply { + setColor(context.getColor(R.color.sheet_bg)) + cornerRadii = floatArrayOf(dp(24).toFloat(), dp(24).toFloat(), dp(24).toFloat(), dp(24).toFloat(), 0f, 0f, 0f, 0f) + setStroke(dp(1), green) + } + } + private val listener = object : PlaybackCenter.Listener { + override fun onSleepTimerExpired() { dismiss() } + override fun onItemChanged(entry: com.sparktube.app.playback.QueueEntry?) { dismiss() } + } + + init { + root.addView(android.view.View(context).apply { + background = context.getDrawable(R.drawable.bg_sheet_handle) + }, LinearLayout.LayoutParams(dp(36), dp(4)).apply { gravity = Gravity.CENTER_HORIZONTAL; bottomMargin = dp(12) }) + root.addView(TextView(context).apply { + text = context.getString(R.string.sleep_timer_title) + textSize = 22f + setTextColor(textColor) + setTypeface(typeface, Typeface.BOLD) + setPadding(dp(22), dp(8), dp(22), dp(16)) + }) + root.addView(android.view.View(context).apply { setBackgroundColor(context.getColor(R.color.outline)) }, LinearLayout.LayoutParams(-1, dp(1))) + val rows = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } + val mode = PlaybackCenter.sleepTimerMode + addRow(rows, context.getString(R.string.sleep_timer_off), R.drawable.ic_close, mode == SleepTimerState.Mode.OFF) { + PlaybackCenter.clearSleepTimer() + } + for (minutes in listOf(10, 15, 20, 30, 45, 60)) { + val label = if (minutes == 60) context.getString(R.string.sleep_timer_hour) + else context.getString(R.string.sleep_timer_minutes, minutes) + addRow(rows, label, R.drawable.ic_sleep_timer, + mode == SleepTimerState.Mode.DURATION && PlaybackCenter.sleepTimerMinutes == minutes) { + PlaybackCenter.setSleepTimer(minutes) + } + } + val remaining = (PlaybackCenter.durationMs - PlaybackCenter.positionMs).coerceAtLeast(0) + val end = context.getString(R.string.sleep_timer_end) + addRow(rows, if (PlaybackCenter.durationMs > 0) "$end (${formatRemaining(remaining)})" else end, + R.drawable.ic_sleep_timer, mode == SleepTimerState.Mode.END_OF_ITEM) { + PlaybackCenter.setSleepTimerEndOfItem() + } + val scroll = ScrollView(context).apply { isFillViewport = false; addView(rows) } + root.addView(scroll, LinearLayout.LayoutParams(-1, -2)) + setContentView(root) + behavior.skipCollapsed = true + setOnShowListener { + (root.parent as? android.view.View)?.setBackgroundColor(Color.TRANSPARENT) + root.layoutParams = root.layoutParams.apply { + height = minOf(root.measuredHeight, (context.resources.displayMetrics.heightPixels * 0.86f).toInt()) + } + behavior.state = BottomSheetBehavior.STATE_EXPANDED + } + } + + private fun addRow(parent: LinearLayout, label: String, icon: Int, selected: Boolean, action: () -> Unit) { + val tint = if (selected) green else textColor + val row = LinearLayout(context).apply { + gravity = Gravity.CENTER_VERTICAL + orientation = LinearLayout.HORIZONTAL + minimumHeight = dp(48) + setPadding(dp(22), dp(14), dp(22), dp(14)) + isClickable = true + isFocusable = true + isSelected = selected + contentDescription = if (selected) context.getString(R.string.sleep_timer_selected, label) else label + val out = android.util.TypedValue() + context.theme.resolveAttribute(android.R.attr.selectableItemBackground, out, true) + setBackgroundResource(out.resourceId) + setOnClickListener { action(); dismiss() } + } + row.addView(ImageView(context).apply { setImageResource(icon); setColorFilter(tint); importantForAccessibility = android.view.View.IMPORTANT_FOR_ACCESSIBILITY_NO }, LinearLayout.LayoutParams(dp(24), dp(24))) + row.addView(TextView(context).apply { text = label; textSize = 18f; setTextColor(tint); setPadding(dp(20), 0, dp(8), 0) }, LinearLayout.LayoutParams(0, -2, 1f)) + row.addView(ImageView(context).apply { + setImageResource(R.drawable.ic_check); setColorFilter(green) + visibility = if (selected) android.view.View.VISIBLE else android.view.View.INVISIBLE + importantForAccessibility = android.view.View.IMPORTANT_FOR_ACCESSIBILITY_NO + }, LinearLayout.LayoutParams(dp(24), dp(24))) + parent.addView(row, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + } + + override fun onStart() { super.onStart(); PlaybackCenter.addListener(listener) } + override fun onStop() { PlaybackCenter.removeListener(listener); super.onStop() } + + companion object { + private fun formatRemaining(ms: Long): String { + val seconds = (ms.coerceAtLeast(0) + 999) / 1000 + return String.format(Locale.getDefault(), "%d:%02d", seconds / 60, seconds % 60) + } + fun buttonLabel(context: Context): String = when (PlaybackCenter.sleepTimerMode) { + SleepTimerState.Mode.OFF -> context.getString(R.string.sleep_timer_button) + SleepTimerState.Mode.DURATION -> formatRemaining(PlaybackCenter.sleepTimerRemainingMs ?: 0) + SleepTimerState.Mode.END_OF_ITEM -> context.getString(R.string.sleep_timer_end_countdown, + formatRemaining((PlaybackCenter.durationMs - PlaybackCenter.positionMs).coerceAtLeast(0))) + } + fun accessibilityLabel(context: Context): String = context.getString(R.string.sleep_timer_accessibility, buttonLabel(context)) + } +} diff --git a/app/src/main/java/com/sparktube/app/ui/player/DeliberateSeekTimeBar.kt b/app/src/main/java/com/sparktube/app/ui/player/DeliberateSeekTimeBar.kt new file mode 100644 index 0000000..8228091 --- /dev/null +++ b/app/src/main/java/com/sparktube/app/ui/player/DeliberateSeekTimeBar.kt @@ -0,0 +1,181 @@ +package com.sparktube.app.ui.player + +import android.content.Context +import android.content.res.Configuration +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.ViewConfiguration +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.DefaultTimeBar + +/** Native Media3 time bar, with a portrait-only deliberate-drag touch gate. + * Keyboard and accessibility seeking retain Media3's implementation. + */ +@UnstableApi +class DeliberateSeekTimeBar @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : DefaultTimeBar(context, attrs, defStyleAttr, attrs, + androidx.media3.ui.R.style.ExoStyledControls_TimeBar) { + private val gate = SeekBarDragGate() + private var pendingDown: MotionEvent? = null + private var nativeDragging = false + private var portraitSequence = false + private val dragSlop = maxOf(ViewConfiguration.get(context).scaledTouchSlop.toFloat(), + 14f * resources.displayMetrics.density) + + // Read the same style as DefaultTimeBar. Its 48dp seekBounds are intentionally + // NOT used: only the painted 2dp track or the current circular thumb qualify. + private val touchStyle = context.obtainStyledAttributes(attrs, + androidx.media3.ui.R.styleable.DefaultTimeBar, defStyleAttr, + androidx.media3.ui.R.style.ExoStyledControls_TimeBar) + private val trackHeight = touchStyle.getDimensionPixelSize( + androidx.media3.ui.R.styleable.DefaultTimeBar_bar_height, dp(4)) + private val bottomGravity = touchStyle.getInt( + androidx.media3.ui.R.styleable.DefaultTimeBar_bar_gravity, 0) == 1 + private val enabledThumbSize = touchStyle.getDimensionPixelSize( + androidx.media3.ui.R.styleable.DefaultTimeBar_scrubber_enabled_size, dp(12)) + private val draggedThumbSize = touchStyle.getDimensionPixelSize( + androidx.media3.ui.R.styleable.DefaultTimeBar_scrubber_dragged_size, dp(16)) + private val disabledThumbSize = touchStyle.getDimensionPixelSize( + androidx.media3.ui.R.styleable.DefaultTimeBar_scrubber_disabled_size, 0) + private val thumbInset = (maxOf(enabledThumbSize, draggedThumbSize, disabledThumbSize) + 1) / 2 + private var insetDisabled = false + private var thumbReadyAt = 0L + private var touchPositionMs = 0L + private var touchDurationMs = 0L + private var paintedTrack = SeekBarTouchGeometry(0, 0, 0, 0) + + init { touchStyle.recycle() } + + private fun dp(value: Int) = (value * resources.displayMetrics.density + 0.5f).toInt() + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + paintedTrack = SeekBarTouchGeometry.layout(width, height, paddingLeft, paddingRight, + paddingBottom, trackHeight, if (insetDisabled) 0 else thumbInset, bottomGravity) + } + + override fun setPosition(position: Long) { + touchPositionMs = position + super.setPosition(position) + } + + override fun setDuration(duration: Long) { + touchDurationMs = duration + super.setDuration(duration) + } + + override fun showScrubber() { + insetDisabled = false + thumbReadyAt = 0L + super.showScrubber() + } + + override fun showScrubber(animationDuration: Long) { + insetDisabled = false + // During scaling accept only the track, never a larger-than-painted thumb. + thumbReadyAt = android.os.SystemClock.uptimeMillis() + animationDuration + super.showScrubber(animationDuration) + } + + override fun hideScrubber(disableScrubberPadding: Boolean) { + insetDisabled = disableScrubberPadding + thumbReadyAt = Long.MAX_VALUE + super.hideScrubber(disableScrubberPadding) + } + + override fun hideScrubber(animationDuration: Long) { + thumbReadyAt = Long.MAX_VALUE + super.hideScrubber(animationDuration) + } + + /** Used by Activity routing too, so transparent padding remains video surface. */ + fun hitsVisibleSeekBar(x: Float, y: Float): Boolean { + if (!isShown || !isEnabled || alpha <= 0f) return false + val radius = if (android.os.SystemClock.uptimeMillis() >= thumbReadyAt) + (if (isFocused) draggedThumbSize else enabledThumbSize) / 2 else 0 + return paintedTrack.contains(x, y, touchPositionMs, touchDurationMs, radius) + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + cancelPortraitSequence() + portraitSequence = resources.configuration.orientation != Configuration.ORIENTATION_LANDSCAPE + } + // Exact native touch behavior for sequences started in landscape. + if (!portraitSequence) return super.onTouchEvent(event) + if (!isEnabled || resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { + cancelPortraitSequence() + return true + } + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + gate.down(event.x, event.y, event.pointerCount == 1 && hitsVisibleSeekBar(event.x, event.y)) + if (gate.state == SeekBarDragGate.State.REJECTED) return false + pendingDown = MotionEvent.obtain(event) + } + MotionEvent.ACTION_MOVE -> { + if (event.pointerCount != 1) { + cancelPortraitSequence() + gate.reject() + return true + } + if (gate.move(event.x, event.y, dragSlop)) { + if (!nativeDragging) { + val down = pendingDown ?: return true + nativeDragging = super.onTouchEvent(down) + down.recycle() + pendingDown = null + if (!nativeDragging) gate.reject() + } + if (nativeDragging) super.onTouchEvent(event) + } + } + MotionEvent.ACTION_POINTER_DOWN, MotionEvent.ACTION_POINTER_UP -> { + cancelPortraitSequence() + gate.reject() + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + // No DOWN/UP is delivered for taps or rejected drags, so no + // transient thumb jump, scrub listener, or playback seek occurs. + if (nativeDragging) super.onTouchEvent(event) + nativeDragging = false + clearPending() + gate.reset() + portraitSequence = false + } + } + return true + } + + private fun clearPending() { + pendingDown?.recycle() + pendingDown = null + } + + private fun cancelPortraitSequence() { + if (nativeDragging) { + val now = android.os.SystemClock.uptimeMillis() + val cancel = MotionEvent.obtain(now, now, MotionEvent.ACTION_CANCEL, 0f, 0f, 0) + super.onTouchEvent(cancel) + cancel.recycle() + } + nativeDragging = false + clearPending() + gate.reset() + } + + override fun onConfigurationChanged(newConfig: Configuration) { + cancelPortraitSequence() + // Keep an interrupted portrait sequence consumed until its UP/CANCEL; + // never forward an orphan MOVE to the native landscape bar. + super.onConfigurationChanged(newConfig) + } + + override fun onDetachedFromWindow() { + cancelPortraitSequence() + super.onDetachedFromWindow() + } +} diff --git a/app/src/main/java/com/sparktube/app/ui/player/PlayerActivity.kt b/app/src/main/java/com/sparktube/app/ui/player/PlayerActivity.kt index ebfb919..abae69b 100644 --- a/app/src/main/java/com/sparktube/app/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/sparktube/app/ui/player/PlayerActivity.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.Intent import android.content.pm.ActivityInfo import android.content.res.Configuration +import android.media.AudioManager import android.graphics.Typeface import android.graphics.drawable.GradientDrawable import android.os.Build @@ -31,6 +32,7 @@ import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.doOnPreDraw import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams +import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView @@ -83,6 +85,16 @@ class PlayerActivity : AppCompatActivity() { private var swipeTriggered = false private lateinit var gestureDetector: GestureDetector + private var gestureTracking = false + private var gestureStartedInLandscape = false + private var portraitGestureRejected = false + private var gestureStartVolume = 0 + private var gestureStartBrightness = 0.5f + private var gestureAxis = PlayerGestureMath.Axis.NONE + private var gestureStartPosition = 0L + private var pendingSeekTarget: Long? = null + private val hideGestureFeedback = Runnable { binding.gestureFeedback.isVisible = false } + private var retiringPip = false /** Mirrors the built-in controller's visibility (gear / seek controls). */ private var controllerVisible = false @@ -98,21 +110,39 @@ class PlayerActivity : AppCompatActivity() { private val playbackListener = object : PlaybackCenter.Listener { override fun onItemChanged(entry: QueueEntry?) { + updateQueueControls() if (entry != null && entry.url != loadedUrl) { loadedUrl = entry.url bind(entry) } } + override fun onQueueChanged() = updateQueueControls() + override fun onSleepTimerExpired() { finish() } + override fun onSleepTimerChanged() { + binding.actionTimerLabel.text = com.sparktube.app.ui.music.SleepTimerSheet.buttonLabel(this@PlayerActivity) + binding.actionTimer.contentDescription = com.sparktube.app.ui.music.SleepTimerSheet.accessibilityLabel(this@PlayerActivity) + } + + override fun onPlaybackStateChanged(isPlaying: Boolean) = updateQueueControls() + + override fun onVideoFrameRendered() { + binding.errorView.isVisible = false + binding.loading.isVisible = false + } + override fun onResolvingChanged(isResolving: Boolean) { + updateQueueControls() binding.loading.isVisible = isResolving && !binding.errorView.isVisible } override fun onCatalogReady() { + updateQueueControls() bindCatalog() } override fun onError(message: String) { + binding.loading.isVisible = false binding.errorText.text = message binding.errorView.isVisible = true } @@ -138,6 +168,8 @@ class PlayerActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { Themes.apply(this) super.onCreate(savedInstanceState) + // A fresh watch page must not leave the previous pinned Activity behind. + PlaybackCenter.closePreviousPip() // Expands UP out of the mini player (and collapses back down). PanelTransitions.install(this) @@ -176,6 +208,7 @@ class PlayerActivity : AppCompatActivity() { object : PlayerView.ControllerVisibilityListener { override fun onVisibilityChanged(visibility: Int) { controllerVisible = visibility == View.VISIBLE + updateQueueControls() syncFullscreenButtonVisibility() } } @@ -204,17 +237,34 @@ class PlayerActivity : AppCompatActivity() { binding.relatedList.adapter = relatedAdapter binding.retryButton.setOnClickListener { retry() } + // Same collapse action as NowPlayingActivity: keep the shared queue/player alive. + binding.backButton.setOnClickListener { finish() } binding.actionFavorite.setOnClickListener { toggleFavorite() } binding.actionPlaylist.setOnClickListener { showPlaylistSheet() } binding.actionShare.setOnClickListener { shareVideo() } binding.actionDownload.setOnClickListener { onDownloadClicked() } - binding.actionBackground.setOnClickListener { - PlaybackCenter.switchToAudioOnly() - Toast.makeText(this, R.string.background_started, Toast.LENGTH_SHORT).show() - finish() + binding.actionTimerLabel.text = com.sparktube.app.ui.music.SleepTimerSheet.buttonLabel(this) + binding.actionTimer.setOnClickListener { + com.sparktube.app.ui.music.SleepTimerSheet(this).show() } binding.actionPip.setOnClickListener { enterPipOrToast() } + binding.modeSwitch.setOnClickListener { openAudioView() } + binding.playerView.setShowRewindButton(false) + binding.playerView.setShowFastForwardButton(false) + binding.queuePrevious.setOnClickListener { + PlaybackCenter.previous() + binding.playerView.showController() + } + binding.queueNext.setOnClickListener { + PlaybackCenter.next() + binding.playerView.showController() + } + binding.queuePlayPause.setOnClickListener { + PlaybackCenter.togglePlayPause() + binding.playerView.showController() + updateQueueControls() + } binding.channelInfo.setOnClickListener { openChannel() } binding.subscribeButton.setOnClickListener { toggleSubscribe() } @@ -229,37 +279,85 @@ class PlayerActivity : AppCompatActivity() { gestureDetector = GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() { override fun onDown(e: MotionEvent): Boolean { swipeTriggered = false - return false + gestureAxis = PlayerGestureMath.Axis.NONE + portraitGestureRejected = false + gestureStartPosition = PlaybackCenter.positionMs + pendingSeekTarget = null + val audio = getSystemService(Context.AUDIO_SERVICE) as AudioManager + gestureStartVolume = audio.getStreamVolume(AudioManager.STREAM_MUSIC) + gestureStartBrightness = window.attributes.screenBrightness.takeIf { it >= 0f } + ?: (android.provider.Settings.System.getInt(contentResolver, + android.provider.Settings.System.SCREEN_BRIGHTNESS, 128) / 255f) + return true } - override fun onScroll( - e1: MotionEvent?, - e2: MotionEvent, - dx: Float, - dy: Float - ): Boolean { - // Total drag distance from the initial touch (e1) to the - // current position: the per-event dy the API reports is only - // a few pixels per move, so a threshold on it almost never - // fired — the swipe-down-to-minimize felt dead. + override fun onSingleTapConfirmed(e: MotionEvent): Boolean { + if (binding.playerView.isControllerFullyVisible) binding.playerView.hideController() + else binding.playerView.showController() + return true + } + + override fun onDoubleTap(e: MotionEvent): Boolean { + // Preserve intentional double-tap seeks in both orientations. + if (!canSeekFromVideoSurface()) return true + val forward = e.x >= binding.playerView.width / 2f + PlaybackCenter.seekTo(PlayerGestureMath.seekTarget(PlaybackCenter.positionMs, PlaybackCenter.durationMs, forward)) + showGestureFeedback(getString(if (forward) R.string.gesture_seek_forward else R.string.gesture_seek_back)) + return true + } + + override fun onScroll(e1: MotionEvent?, e2: MotionEvent, dx: Float, dy: Float): Boolean { val start = e1 ?: return false - val totalDy = e2.y - start.y - val totalDx = kotlin.math.abs(e2.x - start.x) - if (!swipeTriggered && totalDy > SWIPE_MIN_DISTANCE && totalDy > totalDx * 1.4f) { - swipeTriggered = true - // Swipe down on the video collapses playback into the - // mini player, like the official YouTube app: the playback - // service keeps playing and MainActivity's mini player - // picks the video surface up. - finish() + val totalY = start.y - e2.y + val totalX = e2.x - start.x + if (portraitGestureRejected) return true + if (gestureAxis == PlayerGestureMath.Axis.NONE) { + gestureAxis = PlayerGestureMath.chooseAxis(totalX, totalY, dp(14).toFloat()) + if (PlayerGestureMath.rejectAmbiguousPortraitMovement( + gestureStartedInLandscape, totalX, totalY, dp(14).toFloat())) { + portraitGestureRejected = true + return true + } + if (gestureAxis != PlayerGestureMath.Axis.NONE) { + swipeTriggered = true + } } - return false + if (gestureAxis == PlayerGestureMath.Axis.NONE) return true + if (gestureAxis == PlayerGestureMath.Axis.HORIZONTAL) { + // Still consume this gesture: dragging into the bar must not + // transfer ownership or synthesize a new seek-bar ACTION_DOWN. + if (!canSeekFromVideoSurface()) return true + val pixelsPerTenSeconds = dp(120).toFloat().coerceAtLeast(1f) + pendingSeekTarget = PlayerGestureMath.horizontalSeekTarget( + gestureStartPosition, PlaybackCenter.durationMs, totalX, pixelsPerTenSeconds + ) + showGestureFeedback(formatGestureSeek(pendingSeekTarget!!)) + return true + } + val width = binding.playerView.width + // The center retains swipe-down-to-minimize; sides adjust media levels. + if (start.x > width * 0.35f && start.x < width * 0.65f) { + if (totalY < -dp(100)) finish() + return true + } + // Side brightness and volume adjustments are landscape only. + if (resources.configuration.orientation != Configuration.ORIENTATION_LANDSCAPE) return true + val fraction = totalY / (binding.playerView.height * 0.75f).coerceAtLeast(1f) + if (start.x >= width / 2f) { + val audio = getSystemService(Context.AUDIO_SERVICE) as AudioManager + val max = audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + val volume = PlayerGestureMath.volume(gestureStartVolume, fraction, max) + try { audio.setStreamVolume(AudioManager.STREAM_MUSIC, volume, 0) } + catch (_: SecurityException) { return true } + showGestureFeedback(getString(R.string.gesture_volume, if (max > 0) volume * 100 / max else 0)) + } else { + val brightness = PlayerGestureMath.brightness(gestureStartBrightness, fraction) + window.attributes = window.attributes.apply { screenBrightness = brightness } + showGestureFeedback(getString(R.string.gesture_brightness, (brightness * 100).toInt())) + } + return true } }) - binding.playerView.setOnTouchListener { v, event -> - gestureDetector.onTouchEvent(event) - v.onTouchEvent(event) - } hookGearMenu() @@ -286,6 +384,103 @@ class PlayerActivity : AppCompatActivity() { } } + /** Keep unavailable queue actions visible but dimmed, never replace them with seek. */ + private fun updateQueueControls() { + if (!::binding.isInitialized) return + val player = PlaybackCenter.sessionPlayer + listOf( + binding.queuePrevious to Player.COMMAND_SEEK_TO_PREVIOUS, + binding.queueNext to Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM + ).forEach { (button, command) -> + button.isEnabled = player.isCommandAvailable(command) + button.alpha = if (button.isEnabled) 1f else 0.35f + } + val playing = player.playWhenReady && player.playbackState != Player.STATE_ENDED + binding.queuePlayPause.setImageResource(if (playing) R.drawable.ic_pause else R.drawable.ic_play_arrow) + binding.queuePlayPause.contentDescription = getString( + if (playing) Media3R.string.exo_controls_pause_description else Media3R.string.exo_controls_play_description) + binding.transportControls.isVisible = controllerVisible && !isInPictureInPictureMode + } + + private fun openAudioView() { + PlaybackCenter.switchToAudioOnly() + com.sparktube.app.ui.music.NowPlayingActivity.start(this) + finish() + } + + private fun showGestureFeedback(text: String) { + binding.gestureFeedback.text = text + binding.gestureFeedback.isVisible = true + downloadHandler.removeCallbacks(hideGestureFeedback) + downloadHandler.postDelayed(hideGestureFeedback, 900) + } + + private fun formatGestureSeek(positionMs: Long): String { + val seconds = positionMs / 1_000L + return "${seconds / 60}:${(seconds % 60).toString().padStart(2, '0')}" + } + + private fun canSeekFromVideoSurface(): Boolean = PlayerGestureMath.canSeekVideoSurface( + gestureStartedInLandscape, + resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + ) + + // Dispatch above the controller's empty scrim, but leave real buttons and the seek bar alone. + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + if (!::gestureDetector.isInitialized || isInPictureInPictureMode) return super.dispatchTouchEvent(event) + val point = IntArray(2) + binding.playerView.getLocationOnScreen(point) + val x = event.rawX - point[0] + val y = event.rawY - point[1] + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + portraitGestureRejected = false + gestureStartedInLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + val inside = x >= 0 && x < binding.playerView.width && y >= 0 && y < binding.playerView.height + gestureTracking = inside && !hitsPlayerControl(binding.root, event.rawX.toInt(), event.rawY.toInt()) + } + if (gestureTracking) { + val local = MotionEvent.obtain(event) + local.setLocation(x, y) + if (!gestureStartedInLandscape && (event.pointerCount > 1 || + event.actionMasked == MotionEvent.ACTION_POINTER_DOWN)) { + local.action = MotionEvent.ACTION_CANCEL + gestureDetector.onTouchEvent(local) + portraitGestureRejected = true + pendingSeekTarget = null + } else if (!portraitGestureRejected) { + gestureDetector.onTouchEvent(local) + } + local.recycle() + if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) { + if (event.actionMasked == MotionEvent.ACTION_UP && !portraitGestureRejected && canSeekFromVideoSurface()) { + pendingSeekTarget?.let(PlaybackCenter::seekTo) + } + pendingSeekTarget = null + gestureTracking = false + } + return true + } + return super.dispatchTouchEvent(event) + } + + private fun hitsPlayerControl(view: View, x: Int, y: Int): Boolean { + if (!view.isShown) return false + val rect = android.graphics.Rect() + if (!view.getGlobalVisibleRect(rect) || !rect.contains(x, y)) return false + if (view is DeliberateSeekTimeBar && + resources.configuration.orientation != Configuration.ORIENTATION_LANDSCAPE) { + val location = IntArray(2) + view.getLocationOnScreen(location) + return view.hitsVisibleSeekBar((x - location[0]).toFloat(), (y - location[1]).toFloat()) + } + if (view is android.widget.ImageButton || view is android.widget.Button || + view is androidx.media3.ui.DefaultTimeBar || view === binding.modeSwitch) return true + if (view is ViewGroup) { + for (i in 0 until view.childCount) if (hitsPlayerControl(view.getChildAt(i), x, y)) return true + } + return false + } + /** * Puts quality / speed / audio-track selection behind the gear button of * the player's built-in controller. @@ -295,10 +490,14 @@ class PlayerActivity : AppCompatActivity() { gear?.setOnClickListener { showVideoSettingsSheet() } } - /** Fullscreen + scale buttons follow the built-in controller's visibility. */ + /** All overlay buttons follow the built-in controller's visibility. */ private fun syncFullscreenButtonVisibility() { val visible = !isInPictureInPictureMode && controllerVisible + // Single-tap reveal and auto-hide apply to collapse just like transport. + binding.backButton.isVisible = visible + binding.transportControls.isVisible = visible binding.fullscreenButton.isVisible = visible + binding.modeSwitch.isVisible = visible // Fit / Crop / Stretch is a fullscreen-only control: the portrait // box always shows the full frame, so the button stays hidden there. binding.scaleButton.isVisible = visible && @@ -339,12 +538,17 @@ class PlayerActivity : AppCompatActivity() { val endInset = if (rtl) bars.left else bars.right binding.backButton.updateLayoutParams { - topMargin = bars.top + dp(4) + // Portrait playerView already clears the status bar; don't add it twice. + topMargin = dp(4) + if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) bars.top else 0 marginStart = dp(4) + startInset } binding.fullscreenButton.updateLayoutParams { - topMargin = bars.top + dp(4) marginEnd = endInset + bottomMargin = dp(48) + } + binding.modeSwitch.updateLayoutParams { + topMargin = dp(4) + marginEnd = endInset + dp(4) } binding.scaleButton.updateLayoutParams { topMargin = bars.top + dp(4) @@ -421,24 +625,25 @@ class PlayerActivity : AppCompatActivity() { override fun onStart() { super.onStart() + if (retiringPip || isFinishing) return PlaybackCenter.addListener(playbackListener) + // A visible PiP activity may be STARTED/PAUSED, never RESUMED. + PlaybackCenter.setPipState(binding.playerView, isInPictureInPictureMode, ::closeReplacedPip) + PlaybackCenter.attachView(binding.playerView) + updateQueueControls() // Playback can start resolving BEFORE this page is listening (Play // all / a playlist video starts the queue, then opens the page), so // pick the spinner state up here instead of missing it. binding.loading.isVisible = PlaybackCenter.isResolving && !binding.errorView.isVisible downloadHandler.post(downloadPoll) - if (PlaybackCenter.hasMedia) { - PlaybackCenter.player.playWhenReady = true - } } override fun onResume() { super.onResume() + if (retiringPip || isFinishing) return if (Themes.recreateIfNeeded(this)) return applyCloseTransition() - if (!isInPictureInPictureMode) { - PlaybackCenter.attachView(binding.playerView) - } + PlaybackCenter.attachView(binding.playerView) } // ----- Close animation ----- @@ -472,16 +677,20 @@ class PlayerActivity : AppCompatActivity() { } } - override fun onPause() { - super.onPause() - if (!isInPictureInPictureMode) { - PlaybackCenter.detachView(binding.playerView) - } - } + // Do NOT detach in onPause: Android/ROMs can pause us before delivering + // the PiP-mode callback. PiP stays visible while paused and needs the + // same TextureView output. Detach only once the activity is not visible. override fun onStop() { super.onStop() + PlaybackCenter.detachView(binding.playerView) + // A stopped/temporarily hidden pinned Activity still needs to be + // reachable for replacement cleanup; only a real exit releases it. + if (!isInPictureInPictureMode || isFinishing || retiringPip) { + PlaybackCenter.setPipState(binding.playerView, false) + } downloadHandler.removeCallbacks(downloadPoll) + downloadHandler.removeCallbacks(hideGestureFeedback) PlaybackCenter.removeListener(playbackListener) // Return to sensor portrait so the next video starts the standard way. if (requestedOrientation != ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) { @@ -489,10 +698,27 @@ class PlayerActivity : AppCompatActivity() { } } + override fun onDestroy() { + PlaybackCenter.setPipState(binding.playerView, false) + PlaybackCenter.detachView(binding.playerView) + super.onDestroy() + } + // ----- PiP ----- + private fun closeReplacedPip() { + if (retiringPip) return + retiringPip = true + PlaybackCenter.removeListener(playbackListener) + PlaybackCenter.detachView(binding.playerView) + // Finish the pinned Activity itself, not just its surface or global flag. + // Never stop/pause the shared engine: it is about to play the new item. + finish() + } + override fun onUserLeaveHint() { super.onUserLeaveHint() + if (retiringPip || isFinishing) return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && PlaybackCenter.hasMedia && PlaybackCenter.mode == PlaybackCenter.Mode.VIDEO && !PlaybackCenter.audioOnlyMode ) { @@ -509,7 +735,7 @@ class PlayerActivity : AppCompatActivity() { } private fun enterPip() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || retiringPip || isFinishing) return try { enterPictureInPictureMode( android.app.PictureInPictureParams.Builder() @@ -526,30 +752,34 @@ class PlayerActivity : AppCompatActivity() { newConfig: Configuration ) { super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) - PlaybackCenter.inPip = isInPictureInPictureMode - if (isInPictureInPictureMode) { - // Show the video only: no back button, no fullscreen button, no page. - binding.backButton.isVisible = false - binding.fullscreenButton.isVisible = false - binding.scaleButton.isVisible = false - binding.scrollArea.isVisible = false - binding.loading.isVisible = false - binding.errorView.isVisible = false - // Expand player to the whole window - binding.playerView.updateLayoutParams { - height = ViewGroup.LayoutParams.MATCH_PARENT - dimensionRatio = null - } - // The PiP window has no insets — clear the portrait offset. - applyPlayerTopInset() - } else { - binding.scrollArea.isVisible = true - binding.playerView.updateLayoutParams { - height = 0 - dimensionRatio = "16:9" - } - applyPlayerTopInset() - syncFullscreenButtonVisibility() + if (retiringPip || isFinishing) return + PlaybackCenter.setPipState(binding.playerView, isInPictureInPictureMode, ::closeReplacedPip) + // Recover ownership even if pause/configuration callbacks arrived + // first. attachView is idempotent and uses the raw rendering player. + if (isInPictureInPictureMode) PlaybackCenter.attachView(binding.playerView) + binding.playerView.useController = !isInPictureInPictureMode + applyOrientation(newConfig) + if (!isInPictureInPictureMode) binding.playerView.showController() + } + + private fun applyPipLayout() { + // Both mode and configuration callbacks use this path: orientation + // must never restore the watch page over the tiny PiP video output. + binding.transportControls.isVisible = false + binding.playerView.hideController() + binding.backButton.isVisible = false + binding.fullscreenButton.isVisible = false + binding.scaleButton.isVisible = false + binding.modeSwitch.isVisible = false + binding.gestureFeedback.isVisible = false + binding.scrollArea.isVisible = false + binding.loading.isVisible = false + binding.errorView.isVisible = false + binding.playerView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT + binding.playerView.updateLayoutParams { + height = ViewGroup.LayoutParams.MATCH_PARENT + dimensionRatio = null + topMargin = 0 } } @@ -561,7 +791,36 @@ class PlayerActivity : AppCompatActivity() { } private fun applyOrientation(config: Configuration) { + if (isInPictureInPictureMode) { + applyPipLayout() + return + } val landscape = config.orientation == Configuration.ORIENTATION_LANDSCAPE + if (!landscape) { + // Do not carry a fullscreen-only brightness override back to portrait. + window.attributes = window.attributes.apply { + screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE + } + binding.gestureFeedback.isVisible = false + } + // This activity handles rotation without reinflating the controller. + // Clear only the portrait bottom strip; restore Media3's original + // background in landscape without changing seek layout or behavior. + binding.playerView.findViewById(Media3R.id.exo_bottom_bar)?.setBackgroundResource( + if (landscape) Media3R.color.exo_bottom_bar_background else android.R.color.transparent + ) + // The portrait-only dimension is also read by Media3's layout manager, + // so resizing cannot put the old 52dp gap back. Apply it immediately + // on rotation too, before the next controller size/layout callback. + binding.playerView.findViewById(Media3R.id.exo_progress) + ?.updateLayoutParams { + bottomMargin = resources.getDimensionPixelSize(Media3R.dimen.exo_styled_progress_margin_bottom) + } + // Keep settings clickable above the lowered 48dp seek touch target. + // Empty transparent row space still passes touches through to the bar. + // Restore the original zero Z/order for landscape controls. + binding.playerView.findViewById(Media3R.id.exo_bottom_bar)?.translationZ = + if (landscape) 0f else dp(1).toFloat() binding.fullscreenButton.setImageResource( if (landscape) R.drawable.ic_fullscreen_exit else R.drawable.ic_fullscreen ) @@ -841,7 +1100,7 @@ class PlayerActivity : AppCompatActivity() { confirmDeleteDownload() } DownloadCenter.hasActiveDownload(this, entry.url) -> { - Toast.makeText(this, R.string.download_status_running, Toast.LENGTH_SHORT).show() + com.sparktube.app.ui.MainActivity.openDownloads(this) } else -> showDownloadSheet() } @@ -997,6 +1256,8 @@ class PlayerActivity : AppCompatActivity() { private val downloadPoll = object : Runnable { override fun run() { + binding.actionTimerLabel.text = com.sparktube.app.ui.music.SleepTimerSheet.buttonLabel(this@PlayerActivity) + binding.actionTimer.contentDescription = com.sparktube.app.ui.music.SleepTimerSheet.accessibilityLabel(this@PlayerActivity) val entry = PlaybackCenter.currentEntry if (entry != null && !isFinishing) { // Keep the registry in sync with the built-in download @@ -1078,10 +1339,7 @@ class PlayerActivity : AppCompatActivity() { private fun retry() { binding.errorView.isVisible = false - val entry = PlaybackCenter.currentEntry - if (entry != null) { - PlaybackCenter.playVideo(entry) - } + PlaybackCenter.retryCurrentVideo() } // ----- Bottom sheets ----- diff --git a/app/src/main/java/com/sparktube/app/ui/player/PlayerGestureMath.kt b/app/src/main/java/com/sparktube/app/ui/player/PlayerGestureMath.kt new file mode 100644 index 0000000..e49ffdd --- /dev/null +++ b/app/src/main/java/com/sparktube/app/ui/player/PlayerGestureMath.kt @@ -0,0 +1,48 @@ +package com.sparktube.app.ui.player + +/** Pure calculations shared by touch handling and local JVM regression tests. */ +internal object PlayerGestureMath { + enum class Axis { NONE, HORIZONTAL, VERTICAL } + + /** Intentional surface seeks work in either orientation, never across a rotation. */ + fun canSeekVideoSurface(startedInLandscape: Boolean, isLandscape: Boolean): Boolean = + startedInLandscape == isLandscape + + /** Do not promote an unrelated diagonal portrait drag into a surface seek. */ + fun rejectAmbiguousPortraitMovement(startedInLandscape: Boolean, dx: Float, dy: Float, slop: Float): Boolean = + !startedInLandscape && maxOf(kotlin.math.abs(dx), kotlin.math.abs(dy)) >= slop && + chooseAxis(dx, dy, slop) == Axis.NONE + + /** Lock only after meaningful movement, preferring the clearly dominant axis. */ + fun chooseAxis(dx: Float, dy: Float, slop: Float, dominance: Float = 1.3f): Axis { + val x = kotlin.math.abs(dx) + val y = kotlin.math.abs(dy) + if (maxOf(x, y) < slop) return Axis.NONE + return when { + x > y * dominance -> Axis.HORIZONTAL + y > x * dominance -> Axis.VERTICAL + else -> Axis.NONE + } + } + + /** Horizontal seek: 120 dp of travel corresponds to a 10-second seek. */ + fun horizontalSeekTarget(positionMs: Long, durationMs: Long, deltaX: Float, pixelsPerTenSeconds: Float): Long { + if (durationMs <= 0L || pixelsPerTenSeconds <= 0f) return positionMs.coerceAtLeast(0L) + val current = positionMs.coerceIn(0L, durationMs) + val delta = (deltaX.toDouble() / pixelsPerTenSeconds * 10_000.0).toLong() + return (current.toDouble() + delta.toDouble()).coerceIn(0.0, durationMs.toDouble()).toLong() + } + + fun seekTarget(positionMs: Long, durationMs: Long, forward: Boolean): Long { + if (durationMs <= 0L) return positionMs.coerceAtLeast(0L) + val current = positionMs.coerceIn(0L, durationMs) + return if (forward) current + minOf(10_000L, durationMs - current) + else (current - 10_000L).coerceAtLeast(0L) + } + + fun volume(initial: Int, fraction: Float, max: Int): Int = + (initial + fraction * max.coerceAtLeast(0)).toInt().coerceIn(0, max.coerceAtLeast(0)) + + fun brightness(initial: Float, fraction: Float): Float = + (initial + fraction).coerceIn(0.01f, 1f) +} diff --git a/app/src/main/java/com/sparktube/app/ui/player/SeekBarDragGate.kt b/app/src/main/java/com/sparktube/app/ui/player/SeekBarDragGate.kt new file mode 100644 index 0000000..1a23e24 --- /dev/null +++ b/app/src/main/java/com/sparktube/app/ui/player/SeekBarDragGate.kt @@ -0,0 +1,33 @@ +package com.sparktube.app.ui.player + +/** Touch-only gate: taps never reach Media3, a clear horizontal drag does. */ +internal class SeekBarDragGate { + enum class State { IDLE, PENDING, DRAGGING, REJECTED } + var state = State.IDLE + private set + private var startX = 0f + private var startY = 0f + + fun down(x: Float, y: Float, onVisibleBar: Boolean = true) { + startX = x + startY = y + state = if (onVisibleBar) State.PENDING else State.REJECTED + } + + fun move(x: Float, y: Float, slop: Float): Boolean { + if (state == State.PENDING) { + val dx = x - startX + val dy = y - startY + // Once meaningful non-horizontal movement starts, this sequence + // cannot become a scrub later by wandering sideways into the bar. + if (maxOf(kotlin.math.abs(dx), kotlin.math.abs(dy)) >= slop) { + state = if (PlayerGestureMath.chooseAxis(dx, dy, slop) == PlayerGestureMath.Axis.HORIZONTAL) + State.DRAGGING else State.REJECTED + } + } + return state == State.DRAGGING + } + + fun reset() { state = State.IDLE } + fun reject() { state = State.REJECTED } +} diff --git a/app/src/main/java/com/sparktube/app/ui/player/SeekBarTouchGeometry.kt b/app/src/main/java/com/sparktube/app/ui/player/SeekBarTouchGeometry.kt new file mode 100644 index 0000000..d99e131 --- /dev/null +++ b/app/src/main/java/com/sparktube/app/ui/player/SeekBarTouchGeometry.kt @@ -0,0 +1,29 @@ +package com.sparktube.app.ui.player + +/** Pixel geometry of the painted Media3 track, not its larger invisible touch target. */ +internal data class SeekBarTouchGeometry( + val left: Int, val top: Int, val right: Int, val bottom: Int +) { + fun contains(x: Float, y: Float, positionMs: Long, durationMs: Long, thumbRadius: Int): Boolean { + if (right <= left || bottom <= top || durationMs <= 0 || !x.isFinite() || !y.isFinite()) return false + if (x >= left && x < right && y >= top && y < bottom) return true + if (thumbRadius <= 0) return false + // Match DefaultTimeBar's integer progress position and circle center. + val thumbX = left + ((right - left).toLong() * positionMs.coerceIn(0, durationMs) / durationMs).toInt() + val thumbY = (top + bottom) / 2 + val dx = x - thumbX + val dy = y - thumbY + return dx * dx + dy * dy <= thumbRadius.toFloat() * thumbRadius + } + + companion object { + /** Matches DefaultTimeBar 1.3.1 onLayout, including bottom gravity and thumb inset. */ + fun layout(width: Int, height: Int, paddingLeft: Int, paddingRight: Int, + paddingBottom: Int, barHeight: Int, scrubberPadding: Int, bottomGravity: Boolean): SeekBarTouchGeometry { + val top = if (bottomGravity) height - paddingBottom - barHeight - + maxOf(scrubberPadding - barHeight / 2, 0) else (height - barHeight) / 2 + return SeekBarTouchGeometry(paddingLeft + scrubberPadding, top, + width - paddingRight - scrubberPadding, top + barHeight) + } + } +} diff --git a/app/src/main/res/drawable/bg_mode_switch.xml b/app/src/main/res/drawable/bg_mode_switch.xml new file mode 100644 index 0000000..7aef0f6 --- /dev/null +++ b/app/src/main/res/drawable/bg_mode_switch.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_mode_switch_active.xml b/app/src/main/res/drawable/bg_mode_switch_active.xml new file mode 100644 index 0000000..36a83de --- /dev/null +++ b/app/src/main/res/drawable/bg_mode_switch_active.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_mode_switch_hit_area.xml b/app/src/main/res/drawable/bg_mode_switch_hit_area.xml new file mode 100644 index 0000000..fc8d6de --- /dev/null +++ b/app/src/main/res/drawable/bg_mode_switch_hit_area.xml @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/bg_transport_circle.xml b/app/src/main/res/drawable/bg_transport_circle.xml new file mode 100644 index 0000000..df428b0 --- /dev/null +++ b/app/src/main/res/drawable/bg_transport_circle.xml @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_download_more.xml b/app/src/main/res/drawable/ic_download_more.xml new file mode 100644 index 0000000..a711499 --- /dev/null +++ b/app/src/main/res/drawable/ic_download_more.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/drawable/ic_sleep_timer.xml b/app/src/main/res/drawable/ic_sleep_timer.xml new file mode 100644 index 0000000..4033e5a --- /dev/null +++ b/app/src/main/res/drawable/ic_sleep_timer.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/layout/activity_now_playing.xml b/app/src/main/res/layout/activity_now_playing.xml index f63f681..62ef000 100644 --- a/app/src/main/res/layout/activity_now_playing.xml +++ b/app/src/main/res/layout/activity_now_playing.xml @@ -35,10 +35,52 @@ android:textSize="11sp" android:textStyle="bold" app:layout_constraintBottom_toBottomOf="@id/backButton" - app:layout_constraintEnd_toStartOf="@id/queueButton" + app:layout_constraintEnd_toStartOf="@id/modeSwitch" app:layout_constraintStart_toEndOf="@id/backButton" app:layout_constraintTop_toTopOf="@id/backButton" /> + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + android:importantForAccessibility="no" + app:layout_constraintStart_toStartOf="@id/playerView" + app:layout_constraintEnd_toEndOf="@id/playerView" + app:layout_constraintTop_toTopOf="@id/playerView" + app:layout_constraintBottom_toBottomOf="@id/playerView" /> @@ -274,17 +377,18 @@ + android:id="@+id/actionTimerLabel" + android:text="@string/sleep_timer_button" /> + android:text="@string/retry" /> diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 0efd304..2692f72 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -37,14 +37,6 @@ android:layout_height="1dp" android:layout_weight="1" /> - - + android:paddingBottom="12dp"> - - + + +