diff --git a/android/app/src/main/kotlin/com/novasonic/app/MusicService.kt b/android/app/src/main/kotlin/com/novasonic/app/MusicService.kt index 57ad155..11b4b26 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/MusicService.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/MusicService.kt @@ -6,6 +6,7 @@ import android.os.Handler import android.os.Looper import androidx.media3.common.AudioAttributes import androidx.media3.common.C +import androidx.media3.common.ForwardingPlayer import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player @@ -97,6 +98,28 @@ class MusicService : MediaSessionService() { .setWakeMode(C.WAKE_MODE_NETWORK) // holds WakeLock + WifiLock through Doze .build() + // ForwardingPlayer that always reports next/prev as available commands. + // ExoPlayer drops those commands when its queue has only one item (single-track model), + // which makes Media3 hide the skip buttons in the notification. JS still owns queue + // logic — the MediaSession callback intercepts the commands and delegates to JS. + val sessionPlayer = object : ForwardingPlayer(player) { + override fun getAvailableCommands(): Player.Commands = + super.getAvailableCommands().buildUpon() + .add(Player.COMMAND_SEEK_TO_NEXT) + .add(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) + .add(Player.COMMAND_SEEK_TO_PREVIOUS) + .add(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) + .build() + + override fun isCommandAvailable(command: Int): Boolean = when (command) { + Player.COMMAND_SEEK_TO_NEXT, + Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, + Player.COMMAND_SEEK_TO_PREVIOUS, + Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM -> true + else -> super.isCommandAvailable(command) + } + } + // Tapping the notification opens the app directly to the Now Playing screen. val notifTapIntent = Intent(this, MainActivity::class.java).apply { putExtra("open_now_playing", true) @@ -107,7 +130,7 @@ class MusicService : MediaSessionService() { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) - mediaSession = MediaSession.Builder(this, player) + mediaSession = MediaSession.Builder(this, sessionPlayer) .setBitmapLoader(bitmapLoader) .setSessionActivity(notifTapPending) .setCallback(object : MediaSession.Callback { @@ -148,6 +171,7 @@ class MusicService : MediaSessionService() { // No play() — wait for JS to decide whether to auto-play } } catch (_: Exception) {} + } override fun onGetSession(controllerInfo: MediaSession.ControllerInfo) = mediaSession diff --git a/src/main.js b/src/main.js index ea9259f..12a307a 100644 --- a/src/main.js +++ b/src/main.js @@ -10,18 +10,18 @@ import { toggleRadioMode, starCurrent, downloadCurrent, renderQueue, playPreview, toggleStar, dlSongLocal, _applyRadioBtn, _applyShuffleRepeat, _restoreLastPlayed, setPlayIcon, npToggleStar, updateMediaSession, updateNpColorMode, reconcileNativeState, -} from './player.js'; +} from './player/player.js'; import { navigate, _navGoBack, _navLoad, onHeaderSearch, closeHeaderSearch, onGlobalSearch, s_goArtist, s_goAlbum, playerBarGoArtist, playerBarGoAlbum, searchSimilarArtist, searchAndPreview, loadItunesAlbum, expandArtistBio, loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist, plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres, -} from './views.js'; +} from './views/views.js'; import { loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll, - onImporterInput, resolveImporterLink } from './spotify.js'; -import { resolveYouTubeLink, executeYouTubeDownload } from './youtube.js'; -import { loadSoundCloudImporter, resolveSoundCloudLink, clearSoundCloudPreview, scDlAll, scDlSingle, scDlTrack } from './soundcloud.js'; + onImporterInput, resolveImporterLink } from './views/spotify.js'; +import { resolveYouTubeLink, executeYouTubeDownload } from './views/youtube.js'; +import { loadSoundCloudImporter, resolveSoundCloudLink, clearSoundCloudPreview, scDlAll, scDlSingle, scDlTrack } from './views/soundcloud.js'; import { absInitFromStorage, absOpenSettings, absConnectSettings, absSetLibrary, absDisconnect, absVerifyApiKey, absToggleConnection, absPlayItem, absTogglePlay, absSeek, absSkipSecs, absNextChapter, absPrevChapter, @@ -29,11 +29,11 @@ import { absToggleSpeedPicker, absToggleSleepPicker, absSeekToChapter, absProgDown, absProgMove, absProgUp, loadAbsBooks, absOpenBook, loadAbsBookDetail, absPlayChapter, absLoadSettingsLibraries, absToggleChapterTrack, absInterrupt, -} from './abs.js'; +} from './views/abs.js'; import { openNowPlaying, closeNowPlaying, switchNpTab, npGoArtist, npGoAlbum, openNpMenu, closeNpMenu, -} from './nowplaying.js'; +} from './player/nowplaying.js'; import { openSettings, closeSettings, saveSettings, confirmDisconnect, checkBackend, openSidebar, closeSidebar, switchNavMode, showCtxMenu, @@ -48,7 +48,7 @@ import { toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, autofillItunes, saveMetadata, deleteSong, addRomajiOverrideUI, removeRomajiOverrideUI, openRomajiOverridesModal, closeRomajiOverridesModal, -} from './ui.js'; +} from './ui/ui.js'; // ─── CONNECT ─── async function doConnect() { diff --git a/src/nowplaying.js b/src/player/nowplaying.js similarity index 98% rename from src/nowplaying.js rename to src/player/nowplaying.js index 88abac8..2fe8bbb 100644 --- a/src/nowplaying.js +++ b/src/player/nowplaying.js @@ -1,10 +1,10 @@ -import { S } from './state.js'; -import { esc } from './utils.js'; -import { coverUrl } from './api.js'; +import { S } from '../state.js'; +import { esc } from '../utils.js'; +import { coverUrl } from '../api.js'; // Circular imports — safe (only used inside function bodies) import { playCurrent, _syncNpDirect, dlSongLocal, npToggleStar, _syncOverlayState, isPlaying, seekToMs, updatePlayerUI } from './player.js'; -import { navigate } from './views.js'; -import { openPlModal, showSongInfo } from './ui.js'; +import { navigate } from '../views/views.js'; +import { openPlModal, showSongInfo } from '../ui/ui.js'; // ─── NOW PLAYING OVERLAY ─── export function openNowPlaying() { @@ -148,7 +148,7 @@ async function _loadNpLyrics(songId) { const token = ++_lyricsLoadToken; const s = S.queue[S.qIdx]; - const { getLyricsBySongId } = await import('./api.js'); + const { getLyricsBySongId } = await import('../api.js'); if(token !== _lyricsLoadToken) return; // 1. Navidrome (plugin handles fetching + caching server-side) diff --git a/src/player.js b/src/player/player.js similarity index 99% rename from src/player.js rename to src/player/player.js index e7db00c..d7de7d3 100644 --- a/src/player.js +++ b/src/player/player.js @@ -1,8 +1,8 @@ -import { S } from './state.js'; -import { esc, formatDur, toast } from './utils.js'; -import { api, streamUrl, coverUrl, downloadUrl, dispTitle, dispArtist, dispArtistParts, dispAlbum } from './api.js'; +import { S } from '../state.js'; +import { esc, formatDur, toast } from '../utils.js'; +import { api, streamUrl, coverUrl, downloadUrl, dispTitle, dispArtist, dispArtistParts, dispAlbum } from '../api.js'; // views.js/ui.js circular imports — safe (only used inside function bodies) -import { loadRecommendations } from './views.js'; +import { loadRecommendations } from '../views/views.js'; import { updateLyricsHighlight, onNpSongChange, syncNowPlaying } from './nowplaying.js'; // ─── NATIVE BRIDGE DETECTION ─────────────────────────────────────────────── diff --git a/src/components.js b/src/ui/components.js similarity index 96% rename from src/components.js rename to src/ui/components.js index 8994833..5acdee3 100644 --- a/src/components.js +++ b/src/ui/components.js @@ -1,5 +1,5 @@ -import { S } from './state.js'; -import { esc, formatDur } from './utils.js'; +import { S } from '../state.js'; +import { esc, formatDur } from '../utils.js'; // Exposed on window so inline onerror= handlers can call it. // Retries the same src up to 3 times with increasing delays, then shows fallback. @@ -8,11 +8,11 @@ window._imgRetry = function(img, fallback) { if (n > 3 || !img.src) { img.parentNode.innerHTML = `