2626import com .hhst .youtubelite .webview .YoutubeWebview ;
2727
2828import java .io .IOException ;
29+ import java .io .InputStream ;
2930import java .net .HttpURLConnection ;
3031import java .net .URL ;
32+ import java .util .concurrent .ExecutorService ;
3133import java .util .concurrent .Executors ;
3234
33-
3435public class PlaybackService extends Service {
3536
36- private MediaSessionCompat mediaSession ;
37+ private static final String TAG = "PlaybackService" ;
3738 private static final String CHANNEL_ID = "player_channel" ;
3839 private static final int NOTIFICATION_ID = 100 ;
39- private NotificationManager notificationManager ;
4040
41- @ Override
42- public int onStartCommand (Intent intent , int flags , int startId ) {
43- MediaButtonReceiver .handleIntent (mediaSession , intent );
44- return super .onStartCommand (intent , flags , startId );
45- }
41+ private MediaSessionCompat mediaSession ;
42+ private NotificationManager notificationManager ;
43+ private final Handler handler = new Handler (Looper .getMainLooper ());
44+ private final ExecutorService executorService = Executors .newSingleThreadExecutor ();
4645
4746 public class PlaybackBinder extends Binder {
4847 public PlaybackService getService () {
4948 return PlaybackService .this ;
5049 }
51-
5250 }
5351
5452 @ Nullable
@@ -57,210 +55,228 @@ public IBinder onBind(Intent intent) {
5755 return new PlaybackBinder ();
5856 }
5957
58+ @ Override
59+ public void onCreate () {
60+ super .onCreate ();
61+ notificationManager = (NotificationManager ) getSystemService (Context .NOTIFICATION_SERVICE );
62+
63+ NotificationChannel channel = new NotificationChannel (
64+ CHANNEL_ID ,
65+ "Player Controls" ,
66+ NotificationManager .IMPORTANCE_LOW );
67+ channel .setDescription ("Media playback controls" );
68+ channel .setShowBadge (false );
69+ channel .setLockscreenVisibility (Notification .VISIBILITY_PUBLIC );
70+ notificationManager .createNotificationChannel (channel );
71+
72+ mediaSession = new MediaSessionCompat (this , TAG );
73+ PlaybackStateCompat initialState = new PlaybackStateCompat .Builder ()
74+ .setActions (PlaybackStateCompat .ACTION_PLAY | PlaybackStateCompat .ACTION_PAUSE |
75+ PlaybackStateCompat .ACTION_PLAY_PAUSE |
76+ PlaybackStateCompat .ACTION_SKIP_TO_PREVIOUS | PlaybackStateCompat .ACTION_SKIP_TO_NEXT |
77+ PlaybackStateCompat .ACTION_SEEK_TO )
78+ .setState (PlaybackStateCompat .STATE_NONE , 0 , 1.0f )
79+ .build ();
80+ mediaSession .setPlaybackState (initialState );
81+ }
82+
83+ @ Override
84+ public int onStartCommand (Intent intent , int flags , int startId ) {
85+ MediaButtonReceiver .handleIntent (mediaSession , intent );
86+ return super .onStartCommand (intent , flags , startId );
87+ }
88+
89+ private boolean isSeeking = false ;
90+ private final Runnable resetSeekFlagRunnable = () -> isSeeking = false ;
6091 public void initialize (YoutubeWebview webview ) {
92+ if (webview == null ) {
93+ stopSelf ();
94+ return ;
95+ }
96+
6197 mediaSession .setCallback (new MediaSessionCompat .Callback () {
6298 @ Override
6399 public void onPlay () {
64- super .onPlay ();
65100 webview .evaluateJavascript ("window.dispatchEvent(new Event('play'));" , null );
66-
67- // --- Update Notification ---
68- Notification updatedNotification = buildNotification (true );
69- if (updatedNotification != null && notificationManager != null ) {
70- notificationManager .notify (NOTIFICATION_ID , updatedNotification );
71- }
72- // --- End Update ---
73101 }
74102
75103 @ Override
76104 public void onPause () {
77- super .onPause ();
78105 webview .evaluateJavascript ("window.dispatchEvent(new Event('pause'));" , null );
79-
80- // --- Update Notification ---
81- Notification updatedNotification = buildNotification (false );
82- if (updatedNotification != null && notificationManager != null ) {
83- notificationManager .notify (NOTIFICATION_ID , updatedNotification );
84- }
85- // --- End Update ---
86106 }
87107
88108 @ Override
89109 public void onSkipToNext () {
90- super .onSkipToNext ();
91110 webview .evaluateJavascript ("window.dispatchEvent(new Event('skipToNext'));" , null );
92111 }
93112
94113 @ Override
95114 public void onSkipToPrevious () {
96- super .onSkipToPrevious ();
97115 webview .evaluateJavascript ("window.dispatchEvent(new Event('skipToPrevious'));" , null );
98116 }
99117
100118 @ SuppressLint ("DefaultLocale" )
101119 @ Override
102120 public void onSeekTo (long pos ) {
103- super .onSeekTo (pos );
121+ isSeeking = true ;
122+ handler .removeCallbacks (resetSeekFlagRunnable );
123+ handler .postDelayed (resetSeekFlagRunnable , 1000 );
124+
125+ long seekSeconds = Math .round (pos / 1000f );
104126 webview .evaluateJavascript (String .format (
105127 "window.dispatchEvent(new CustomEvent('seek', { detail: { time: %d } }));" ,
106- Math . round ( pos / 1000f )
128+ seekSeconds
107129 ), null );
108130 }
109131 });
110132 mediaSession .setActive (true );
111133 }
112134
113-
114135 private Bitmap fetchThumbnail (String url ) {
136+ if (url == null || url .isEmpty ()) return null ;
137+ Bitmap bitmap = null ;
138+ HttpURLConnection conn = null ;
139+ InputStream inputStream = null ;
115140 try {
116- HttpURLConnection conn = (HttpURLConnection ) new URL (url ).openConnection ();
141+ conn = (HttpURLConnection ) new URL (url ).openConnection ();
117142 conn .setConnectTimeout (5000 );
118- Bitmap original = BitmapFactory .decodeStream (conn .getInputStream ());
119-
120- // centered clip
121- int width = original .getWidth ();
122- int height = original .getHeight ();
123-
124- int size = Math .min (width , height );
125-
126- int x = (width - size ) / 2 ;
127- int y = (height - size ) / 2 ;
128-
129- return Bitmap .createBitmap (original , x , y , size , size );
143+ conn .setReadTimeout (10000 );
144+ conn .connect ();
145+ int responseCode = conn .getResponseCode ();
146+ if (responseCode == HttpURLConnection .HTTP_OK ) {
147+ inputStream = conn .getInputStream ();
148+ Bitmap original = BitmapFactory .decodeStream (inputStream );
149+ if (original != null ) {
150+ int size = Math .min (original .getWidth (), original .getHeight ());
151+ int x = (original .getWidth () - size ) / 2 ;
152+ int y = (original .getHeight () - size ) / 2 ;
153+ bitmap = Bitmap .createBitmap (original , x , y , size , size );
154+ if (bitmap != original ) original .recycle ();
155+ }
156+ }
130157 } catch (IOException e ) {
131- Log .e ("fetch thumbnail error" , Log .getStackTraceString (e ));
132- return null ;
158+ Log .e (TAG , "fetchThumbnail IOException: " + e .getMessage ());
159+ } finally {
160+ if (inputStream != null ) {
161+ try { inputStream .close (); } catch (IOException ignored ) {}
162+ }
163+ if (conn != null ) conn .disconnect ();
133164 }
165+ return bitmap ;
134166 }
135167
136- private final Handler handler = new Handler (Looper .getMainLooper ());
137-
138- private long lastProgressPos = 0L ;
139- private final Runnable timeoutRunnable = () -> updateProgress (lastProgressPos , 1f , false );
140-
141- public void updateProgress (long pos , float playbackSpeed , boolean isPlaying ) {
142- handler .removeCallbacks (timeoutRunnable );
143- handler .postDelayed (timeoutRunnable , 1000 );
144- lastProgressPos = pos ;
145- var state = isPlaying ? PlaybackStateCompat .STATE_PLAYING : PlaybackStateCompat .STATE_PAUSED ;
146- PlaybackStateCompat playbackState = new PlaybackStateCompat .Builder ()
147- .setActions (PlaybackStateCompat .ACTION_PLAY | PlaybackStateCompat .ACTION_PAUSE |
148- PlaybackStateCompat .ACTION_SKIP_TO_PREVIOUS | PlaybackStateCompat .ACTION_SKIP_TO_NEXT |
149- PlaybackStateCompat .ACTION_SEEK_TO )
150- .setState (state , pos , playbackSpeed )
151- .build ();
152- mediaSession .setPlaybackState (playbackState );
153- }
154-
155- // Helper method to build the notification based on current state
156168 private Notification buildNotification (boolean isPlaying ) {
157- // 1. Get current metadata from MediaSession
158169 MediaMetadataCompat metadata = mediaSession .getController ().getMetadata ();
159- if (metadata == null ) {
160- // Handle case where metadata isn't available yet
161- Log .w ("PlaybackService" , "Cannot build notification: Metadata is null" );
162- return null ;
163- }
170+ if (metadata == null ) return null ;
164171
165- Bitmap largeIcon = metadata .getBitmap (MediaMetadataCompat .METADATA_KEY_ALBUM_ART );
166172 String title = metadata .getString (MediaMetadataCompat .METADATA_KEY_TITLE );
167173 String artist = metadata .getString (MediaMetadataCompat .METADATA_KEY_ARTIST );
174+ Bitmap largeIcon = metadata .getBitmap (MediaMetadataCompat .METADATA_KEY_ALBUM_ART );
168175
169- // 2. Choose icon and title based on isPlaying
170176 int playPauseIconResId = isPlaying ? R .drawable .ic_pause : R .drawable .ic_play ;
171- String playPauseActionTitle = isPlaying ? "Pause" : "Play" ;
177+ String playPauseActionTitle = isPlaying ? getString ( R . string . action_pause ) : getString ( R . string . action_play ) ;
172178
173- // 3. Rebuild PendingIntent for app launch
174- Intent intent = getPackageManager ().getLaunchIntentForPackage (getPackageName ());
175- if (intent == null ) {
176- intent = new Intent (this , MainActivity .class );
177- }
178- intent .setFlags (Intent .FLAG_ACTIVITY_CLEAR_TOP | Intent .FLAG_ACTIVITY_SINGLE_TOP );
179- PendingIntent pendingIntent = PendingIntent .getActivity (
180- this , 101 , intent ,
181- PendingIntent .FLAG_UPDATE_CURRENT | PendingIntent .FLAG_IMMUTABLE
182- );
183-
184- // 4. Build the notification
185- NotificationCompat .Builder builder = new NotificationCompat .Builder (this , CHANNEL_ID )
179+ PendingIntent playPauseActionIntent = MediaButtonReceiver .buildMediaButtonPendingIntent (this , PlaybackStateCompat .ACTION_PLAY_PAUSE );
180+ PendingIntent prevActionIntent = MediaButtonReceiver .buildMediaButtonPendingIntent (this , PlaybackStateCompat .ACTION_SKIP_TO_PREVIOUS );
181+ PendingIntent nextActionIntent = MediaButtonReceiver .buildMediaButtonPendingIntent (this , PlaybackStateCompat .ACTION_SKIP_TO_NEXT );
182+
183+ Intent launchIntent = getPackageManager ().getLaunchIntentForPackage (getPackageName ());
184+ if (launchIntent == null ) launchIntent = new Intent (this , MainActivity .class );
185+ launchIntent .setFlags (Intent .FLAG_ACTIVITY_CLEAR_TOP | Intent .FLAG_ACTIVITY_SINGLE_TOP );
186+ PendingIntent contentIntent = PendingIntent .getActivity (this , 101 , launchIntent ,
187+ PendingIntent .FLAG_UPDATE_CURRENT | PendingIntent .FLAG_IMMUTABLE );
188+
189+ return new NotificationCompat .Builder (this , CHANNEL_ID )
186190 .setSmallIcon (R .drawable .ic_launcher_foreground )
187191 .setContentTitle (title )
188192 .setContentText (artist )
189193 .setLargeIcon (largeIcon )
190- .setContentIntent (pendingIntent )
191- .setOngoing ( isPlaying ) // Keep notification when playing
192- // --- Actions ---
193- .addAction ( R . drawable . ic_previous , "Previous" , MediaButtonReceiver . buildMediaButtonPendingIntent ( this , PlaybackStateCompat . ACTION_SKIP_TO_PREVIOUS ) )
194- .addAction (playPauseIconResId , playPauseActionTitle , MediaButtonReceiver . buildMediaButtonPendingIntent ( this , PlaybackStateCompat . ACTION_PLAY_PAUSE )) // Dynamic Icon
195- .addAction (R . drawable . ic_next , "Next" , MediaButtonReceiver . buildMediaButtonPendingIntent ( this , PlaybackStateCompat . ACTION_SKIP_TO_NEXT ) )
196- // --- Media Style ---
194+ .setContentIntent (contentIntent )
195+ .setDeleteIntent ( MediaButtonReceiver . buildMediaButtonPendingIntent ( this , PlaybackStateCompat . ACTION_STOP ))
196+ . setVisibility ( NotificationCompat . VISIBILITY_PUBLIC )
197+ .setOngoing ( isPlaying )
198+ .addAction (R . drawable . ic_previous , getString ( R . string . action_previous ), prevActionIntent )
199+ .addAction (playPauseIconResId , playPauseActionTitle , playPauseActionIntent )
200+ . addAction ( R . drawable . ic_next , getString ( R . string . action_next ), nextActionIntent )
197201 .setStyle (new androidx .media .app .NotificationCompat .MediaStyle ()
198202 .setMediaSession (mediaSession .getSessionToken ())
199- .setShowActionsInCompactView (0 , 1 , 2 ));
200-
201- return builder .build ();
203+ .setShowActionsInCompactView (0 , 1 , 2 ))
204+ .build ();
202205 }
203206
204207 public void showNotification (String title , String author , String thumbnail , long duration ) {
205- Executors . newSingleThreadExecutor () .execute (() -> {
208+ executorService .execute (() -> {
206209 Bitmap largeIcon = fetchThumbnail (thumbnail );
207-
208- // Build initial metadata
209210 MediaMetadataCompat metadata = new MediaMetadataCompat .Builder ()
210- .putBitmap (MediaMetadataCompat .METADATA_KEY_ALBUM_ART , largeIcon )
211211 .putString (MediaMetadataCompat .METADATA_KEY_TITLE , title )
212212 .putString (MediaMetadataCompat .METADATA_KEY_ARTIST , author )
213+ .putBitmap (MediaMetadataCompat .METADATA_KEY_ALBUM_ART , largeIcon )
213214 .putLong (MediaMetadataCompat .METADATA_KEY_DURATION , duration * 1000 )
214215 .build ();
215- mediaSession .setMetadata (metadata ); // Set metadata FIRST
216+ mediaSession .setMetadata (metadata );
216217
217- // Build initial playback state
218218 PlaybackStateCompat initialState = new PlaybackStateCompat .Builder ()
219219 .setActions (PlaybackStateCompat .ACTION_PLAY | PlaybackStateCompat .ACTION_PAUSE |
220+ PlaybackStateCompat .ACTION_PLAY_PAUSE |
220221 PlaybackStateCompat .ACTION_SKIP_TO_PREVIOUS | PlaybackStateCompat .ACTION_SKIP_TO_NEXT |
221222 PlaybackStateCompat .ACTION_SEEK_TO )
222- .setState (PlaybackStateCompat .STATE_PAUSED , 0L , 1f )
223+ .setState (PlaybackStateCompat .STATE_PAUSED , 0L , 1.0f )
223224 .build ();
224- mediaSession .setPlaybackState (initialState ); // Set the state in the session
225-
226- // Build the initial notification
227- Notification initialNotification = buildNotification (true );
228-
229- if (initialNotification != null ) {
230- // Start foreground service with the initial notification
231- startForeground (NOTIFICATION_ID , initialNotification );
232- } else {
233- Log .e ("PlaybackService" , "Failed to create initial notification." );
225+ mediaSession .setPlaybackState (initialState );
226+
227+ Notification notification = buildNotification (false );
228+ if (notification != null ) {
229+ try {
230+ startForeground (NOTIFICATION_ID , notification );
231+ } catch (Exception e ) {
232+ Log .e (TAG , "startForeground failed: " + e .getMessage ());
233+ }
234234 }
235235 });
236236 }
237237
238- public void hideNotification () {
239- stopForeground (true );
240- notificationManager .cancelAll ();
241- }
242238
243- @ Override
244- public void onCreate () {
245- super .onCreate ();
246- mediaSession = new MediaSessionCompat (this , "MediaSession" );
247239
248- // create notification channel
249- var channel = new NotificationChannel (
250- CHANNEL_ID ,
251- "Player Channel" ,
252- NotificationManager .IMPORTANCE_LOW );
253- channel .setDescription ("Channel for player controller notifications" );
254- notificationManager = (NotificationManager ) getSystemService (Context .NOTIFICATION_SERVICE );
255- notificationManager .createNotificationChannel (channel );
256- }
240+ private boolean lastIsPlayingState = false ;
241+ private long lastProgressPos = 0L ;
242+ private final Runnable timeoutRunnable = () -> updateProgress (lastProgressPos , 1f , false );
243+
244+ public void updateProgress (long pos , float playbackSpeed , boolean isPlaying ) {
245+ if (isSeeking ) return ;
246+
247+ handler .removeCallbacks (timeoutRunnable );
248+ handler .postDelayed (timeoutRunnable , 1000 );
249+ lastProgressPos = pos ;
257250
251+ int stateCompat = isPlaying ? PlaybackStateCompat .STATE_PLAYING : PlaybackStateCompat .STATE_PAUSED ;
252+ PlaybackStateCompat playbackState = new PlaybackStateCompat .Builder ()
253+ .setActions (PlaybackStateCompat .ACTION_PLAY | PlaybackStateCompat .ACTION_PAUSE |
254+ PlaybackStateCompat .ACTION_PLAY_PAUSE |
255+ PlaybackStateCompat .ACTION_SKIP_TO_PREVIOUS | PlaybackStateCompat .ACTION_SKIP_TO_NEXT |
256+ PlaybackStateCompat .ACTION_SEEK_TO )
257+ .setState (stateCompat , pos , playbackSpeed )
258+ .build ();
259+
260+ mediaSession .setPlaybackState (playbackState );
261+ if (isPlaying != lastIsPlayingState ) {
262+ Notification updatedNotification = buildNotification (isPlaying );
263+ if (updatedNotification != null ) {
264+ notificationManager .notify (NOTIFICATION_ID , updatedNotification );
265+ }
266+ }
267+ lastIsPlayingState = isPlaying ;
268+ }
258269
259270 @ Override
260271 public void onDestroy () {
261272 super .onDestroy ();
262- stopForeground (true );
263- notificationManager .cancelAll ();
264- mediaSession .release ();
273+ stopForeground (Service .STOP_FOREGROUND_REMOVE );
274+ if (notificationManager != null ) notificationManager .cancelAll ();
275+ if (mediaSession != null ) {
276+ mediaSession .release ();
277+ mediaSession = null ;
278+ }
279+ handler .removeCallbacksAndMessages (null );
280+ executorService .shutdown ();
265281 }
266282}
0 commit comments