Skip to content

Commit 65d5229

Browse files
committed
Add Chinese localization and AirPlay button support for macOS
- Implemented Chinese translations in app_localizations_zh.dart for various UI strings. - Added AirPlayButton widget to show native iOS AirPlay route picker, rendering nothing on non-iOS platforms. - Created Podfile and Podfile.lock for macOS support, including necessary dependencies for Flutter plugins.
1 parent eba35c8 commit 65d5229

106 files changed

Lines changed: 37641 additions & 1066 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/release.yml

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,9 +142,107 @@ jobs:
142142
name: linux-build
143143
path: musly-linux-x64.tar.gz
144144

145+
build-ios:
146+
name: Build iOS IPA
147+
runs-on: macos-latest
148+
149+
steps:
150+
- name: Checkout code
151+
uses: actions/checkout@v4
152+
153+
- name: Setup Flutter
154+
uses: subosito/flutter-action@v2
155+
with:
156+
channel: 'stable'
157+
cache: true
158+
159+
- name: Get dependencies
160+
run: flutter pub get
161+
162+
- name: Generate localizations
163+
run: flutter gen-l10n
164+
165+
- name: Install CocoaPods dependencies
166+
run: |
167+
cd ios
168+
pod install --repo-update
169+
170+
- name: Build iOS (no code sign)
171+
run: flutter build ios --release --no-codesign
172+
173+
- name: Package IPA
174+
run: |
175+
mkdir -p Payload
176+
cp -r build/ios/iphoneos/Runner.app Payload/Runner.app
177+
zip -r musly-ios.ipa Payload
178+
rm -rf Payload
179+
180+
- name: Upload iOS artifact
181+
uses: actions/upload-artifact@v4
182+
with:
183+
name: ios-build
184+
path: musly-ios.ipa
185+
186+
build-macos:
187+
name: Build macOS
188+
runs-on: macos-latest
189+
190+
steps:
191+
- name: Checkout code
192+
uses: actions/checkout@v4
193+
194+
- name: Setup Flutter
195+
uses: subosito/flutter-action@v2
196+
with:
197+
channel: 'stable'
198+
cache: true
199+
200+
- name: Get dependencies
201+
run: flutter pub get
202+
203+
- name: Generate localizations
204+
run: flutter gen-l10n
205+
206+
- name: Install CocoaPods dependencies
207+
run: |
208+
cd macos
209+
pod install --repo-update
210+
211+
- name: Build macOS
212+
run: flutter build macos --release
213+
214+
- name: Get version
215+
id: version
216+
run: |
217+
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
218+
echo "VERSION=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
219+
else
220+
echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
221+
fi
222+
223+
- name: Package DMG
224+
run: |
225+
brew install create-dmg
226+
create-dmg \
227+
--volname "Musly ${{ steps.version.outputs.VERSION }}" \
228+
--window-pos 200 120 \
229+
--window-size 800 400 \
230+
--icon-size 100 \
231+
--icon "Musly.app" 200 190 \
232+
--hide-extension "Musly.app" \
233+
--app-drop-link 600 185 \
234+
"musly-macos.dmg" \
235+
"build/macos/Build/Products/Release/"
236+
237+
- name: Upload macOS artifact
238+
uses: actions/upload-artifact@v4
239+
with:
240+
name: macos-build
241+
path: musly-macos.dmg
242+
145243
create-release:
146244
name: Create GitHub Release
147-
needs: [build-android, build-windows, build-linux]
245+
needs: [build-android, build-windows, build-linux, build-ios, build-macos]
148246
runs-on: ubuntu-latest
149247
permissions:
150248
contents: write
@@ -182,9 +280,15 @@ jobs:
182280
- ARMv7: `app-armeabi-v7a-release.apk` (for older devices)
183281
- x86_64: `app-x86_64-release.apk` (for emulators)
184282
283+
**iOS:**
284+
- `musly-ios.ipa` (sideload with AltStore, Sideloadly, or similar — not signed)
285+
185286
**Windows:**
186287
- `musly-windows-setup.exe` (setup installer, run to install Musly)
187288
289+
**macOS:**
290+
- `musly-macos.dmg` (drag Musly.app to Applications — not notarized)
291+
188292
**Linux:**
189293
- `musly-linux-x64.tar.gz` (extract and run musly)
190294
@@ -197,7 +301,9 @@ jobs:
197301
prerelease: false
198302
files: |
199303
artifacts/android-apk/*.apk
304+
artifacts/ios-build/*.ipa
200305
artifacts/windows-build/*.exe
306+
artifacts/macos-build/*.dmg
201307
artifacts/linux-build/*.tar.gz
202308
env:
203309
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

android/app/src/main/AndroidManifest.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@
9191
<intent-filter>
9292
<action android:name="android.media.browse.MediaBrowserService"/>
9393
</intent-filter>
94+
<meta-data
95+
android:name="android.media.browse.SEARCH_SUPPORTED"
96+
android:value="true"/>
9497
</service>
9598

9699
<!-- Media Button Receiver -->

android/app/src/main/kotlin/com/musly/musly/AndroidAutoPlugin.kt

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ object AndroidAutoPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
2222
private var eventSink: EventChannel.EventSink? = null
2323
private var context: Context? = null
2424
private val mainHandler = Handler(Looper.getMainLooper())
25+
26+
// Buffers for library data sent before MusicService finishes starting.
27+
private var pendingRecentSongs: List<Map<String, Any>>? = null
28+
private var pendingAlbums: List<Map<String, Any>>? = null
29+
private var pendingArtists: List<Map<String, Any>>? = null
30+
private var pendingPlaylists: List<Map<String, Any>>? = null
2531

2632
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
2733
context = binding.applicationContext
@@ -89,22 +95,54 @@ object AndroidAutoPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
8995
}
9096
"updateRecentSongs" -> {
9197
val songs = call.argument<List<Map<String, Any>>>("songs") ?: emptyList()
92-
MusicService.getInstance()?.updateRecentSongs(songs)
98+
val svc = MusicService.getInstance()
99+
if (svc == null) {
100+
Log.d(TAG, "MusicService not ready, buffering ${songs.size} recent songs")
101+
pendingRecentSongs = songs
102+
startMusicService()
103+
mainHandler.postDelayed({ flushPendingLibraryData() }, 800)
104+
} else {
105+
svc.updateRecentSongs(songs)
106+
}
93107
result.success(null)
94108
}
95109
"updateAlbums" -> {
96110
val albums = call.argument<List<Map<String, Any>>>("albums") ?: emptyList()
97-
MusicService.getInstance()?.updateAlbums(albums)
111+
val svc = MusicService.getInstance()
112+
if (svc == null) {
113+
Log.d(TAG, "MusicService not ready, buffering ${albums.size} albums")
114+
pendingAlbums = albums
115+
startMusicService()
116+
mainHandler.postDelayed({ flushPendingLibraryData() }, 800)
117+
} else {
118+
svc.updateAlbums(albums)
119+
}
98120
result.success(null)
99121
}
100122
"updateArtists" -> {
101123
val artists = call.argument<List<Map<String, Any>>>("artists") ?: emptyList()
102-
MusicService.getInstance()?.updateArtists(artists)
124+
val svc = MusicService.getInstance()
125+
if (svc == null) {
126+
Log.d(TAG, "MusicService not ready, buffering ${artists.size} artists")
127+
pendingArtists = artists
128+
startMusicService()
129+
mainHandler.postDelayed({ flushPendingLibraryData() }, 800)
130+
} else {
131+
svc.updateArtists(artists)
132+
}
103133
result.success(null)
104134
}
105135
"updatePlaylists" -> {
106136
val playlists = call.argument<List<Map<String, Any>>>("playlists") ?: emptyList()
107-
MusicService.getInstance()?.updatePlaylists(playlists)
137+
val svc = MusicService.getInstance()
138+
if (svc == null) {
139+
Log.d(TAG, "MusicService not ready, buffering ${playlists.size} playlists")
140+
pendingPlaylists = playlists
141+
startMusicService()
142+
mainHandler.postDelayed({ flushPendingLibraryData() }, 800)
143+
} else {
144+
svc.updatePlaylists(playlists)
145+
}
108146
result.success(null)
109147
}
110148
"updateAlbumSongs" -> {
@@ -125,10 +163,41 @@ object AndroidAutoPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
125163
MusicService.getInstance()?.updatePlaylistSongs(playlistId, songs)
126164
result.success(null)
127165
}
166+
"updateSearchResults" -> {
167+
val query = call.argument<String>("query") ?: ""
168+
val songs = call.argument<List<Map<String, Any>>>("songs") ?: emptyList()
169+
MusicService.getInstance()?.updateSearchResults(query, songs)
170+
result.success(null)
171+
}
128172
else -> result.notImplemented()
129173
}
130174
}
131175

176+
/** Called by MusicService once it is fully ready, or from postDelayed retries. */
177+
fun flushPendingLibraryData() {
178+
val svc = MusicService.getInstance() ?: return
179+
pendingRecentSongs?.let {
180+
Log.d(TAG, "Flushing ${it.size} buffered recent songs to MusicService")
181+
svc.updateRecentSongs(it)
182+
pendingRecentSongs = null
183+
}
184+
pendingAlbums?.let {
185+
Log.d(TAG, "Flushing ${it.size} buffered albums to MusicService")
186+
svc.updateAlbums(it)
187+
pendingAlbums = null
188+
}
189+
pendingArtists?.let {
190+
Log.d(TAG, "Flushing ${it.size} buffered artists to MusicService")
191+
svc.updateArtists(it)
192+
pendingArtists = null
193+
}
194+
pendingPlaylists?.let {
195+
Log.d(TAG, "Flushing ${it.size} buffered playlists to MusicService")
196+
svc.updatePlaylists(it)
197+
pendingPlaylists = null
198+
}
199+
}
200+
132201
fun startMusicService() {
133202
context?.let { ctx ->
134203
try {

android/app/src/main/kotlin/com/musly/musly/MusicService.kt

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class MusicService : MediaBrowserServiceCompat() {
3838
const val MEDIA_ID_ALBUMS = "ALBUMS"
3939
const val MEDIA_ID_ARTISTS = "ARTISTS"
4040
const val MEDIA_ID_PLAYLISTS = "PLAYLISTS"
41+
const val MEDIA_ID_SEARCH = "SEARCH"
4142

4243
@Volatile
4344
private var instance: MusicService? = null
@@ -73,6 +74,7 @@ class MusicService : MediaBrowserServiceCompat() {
7374
private val pendingAlbumResults = mutableMapOf<String, Result<MutableList<MediaBrowserCompat.MediaItem>>>()
7475
private val pendingArtistResults = mutableMapOf<String, Result<MutableList<MediaBrowserCompat.MediaItem>>>()
7576
private val pendingPlaylistResults = mutableMapOf<String, Result<MutableList<MediaBrowserCompat.MediaItem>>>()
77+
private val pendingSearchResults = mutableMapOf<String, Result<MutableList<MediaBrowserCompat.MediaItem>>>()
7678

7779
override fun onCreate() {
7880
super.onCreate()
@@ -83,6 +85,10 @@ class MusicService : MediaBrowserServiceCompat() {
8385
initializeMediaSession()
8486

8587
showIdleNotification()
88+
89+
// Deliver any library data that was sent to AndroidAutoPlugin before this
90+
// service finished starting (race condition at app launch).
91+
AndroidAutoPlugin.flushPendingLibraryData()
8692
}
8793

8894
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -159,7 +165,8 @@ class MusicService : MediaBrowserServiceCompat() {
159165
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
160166
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
161167
PlaybackStateCompat.ACTION_SEEK_TO or
162-
PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID
168+
PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or
169+
PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH
163170
)
164171

165172
setPlaybackState(stateBuilder.build())
@@ -179,6 +186,34 @@ class MusicService : MediaBrowserServiceCompat() {
179186
return BrowserRoot(MEDIA_ID_ROOT, null)
180187
}
181188

189+
override fun onSearch(
190+
query: String,
191+
extras: Bundle?,
192+
result: Result<MutableList<MediaBrowserCompat.MediaItem>>
193+
) {
194+
result.detach()
195+
pendingSearchResults[query] = result
196+
AndroidAutoPlugin.sendCommand("search", mapOf("query" to query))
197+
// Timeout after 10 seconds to avoid hanging the UI
198+
serviceScope.launch {
199+
delay(10000)
200+
pendingSearchResults.remove(query)?.sendResult(mutableListOf())
201+
}
202+
}
203+
204+
fun updateSearchResults(query: String, songs: List<Map<String, Any>>) {
205+
val items = songs.map { song ->
206+
createPlayableMediaItem(
207+
song["id"] as? String ?: "",
208+
song["title"] as? String ?: "",
209+
song["artist"] as? String ?: "",
210+
song["album"] as? String ?: "",
211+
song["artworkUrl"] as? String
212+
)
213+
}.toMutableList()
214+
pendingSearchResults.remove(query)?.sendResult(items)
215+
}
216+
182217
override fun onLoadChildren(
183218
parentId: String,
184219
result: Result<MutableList<MediaBrowserCompat.MediaItem>>
@@ -638,6 +673,11 @@ class MusicService : MediaBrowserServiceCompat() {
638673
AndroidAutoPlugin.sendCommand("playFromMediaId", mapOf("mediaId" to it))
639674
}
640675
}
676+
677+
override fun onPlayFromSearch(query: String?, extras: Bundle?) {
678+
val q = query?.trim() ?: ""
679+
AndroidAutoPlugin.sendCommand("playFromSearch", mapOf("query" to q))
680+
}
641681
}
642682

643683
fun setRemoteVolume(isRemote: Boolean, currentVolume: Int) {

ios/Flutter/AppFrameworkInfo.plist

Lines changed: 24 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,24 @@
1-
<?xml version="1.0" encoding="UTF-8"?>
2-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3-
<plist version="1.0">
4-
<dict>
5-
<key>CFBundleDevelopmentRegion</key>
6-
<string>en</string>
7-
<key>CFBundleExecutable</key>
8-
<string>App</string>
9-
<key>CFBundleIdentifier</key>
10-
<string>io.flutter.flutter.app</string>
11-
<key>CFBundleInfoDictionaryVersion</key>
12-
<string>6.0</string>
13-
<key>CFBundleName</key>
14-
<string>App</string>
15-
<key>CFBundlePackageType</key>
16-
<string>FMWK</string>
17-
<key>CFBundleShortVersionString</key>
18-
<string>1.0</string>
19-
<key>CFBundleSignature</key>
20-
<string>????</string>
21-
<key>CFBundleVersion</key>
22-
<string>1.0</string>
23-
<key>MinimumOSVersion</key>
24-
<string>13.0</string>
25-
</dict>
26-
</plist>
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>CFBundleDevelopmentRegion</key>
6+
<string>en</string>
7+
<key>CFBundleExecutable</key>
8+
<string>App</string>
9+
<key>CFBundleIdentifier</key>
10+
<string>io.flutter.flutter.app</string>
11+
<key>CFBundleInfoDictionaryVersion</key>
12+
<string>6.0</string>
13+
<key>CFBundleName</key>
14+
<string>App</string>
15+
<key>CFBundlePackageType</key>
16+
<string>FMWK</string>
17+
<key>CFBundleShortVersionString</key>
18+
<string>1.0</string>
19+
<key>CFBundleSignature</key>
20+
<string>????</string>
21+
<key>CFBundleVersion</key>
22+
<string>1.0</string>
23+
</dict>
24+
</plist>

ios/Flutter/Debug.xcconfig

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
12
#include "Generated.xcconfig"

ios/Flutter/Release.xcconfig

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
12
#include "Generated.xcconfig"

0 commit comments

Comments
 (0)