[READ-ONLY] Mirror of https://github.com/probablykasper/ferrum. Music library app for Mac, Linux and Windows ferrum.kasper.space
electron linux macos music music-library music-player napi windows
0

Configure Feed

Select the types of activity you want to include in your feed.

Add track streaming links

Kasper (Mar 8, 2026, 5:16 AM +0100) 1b22cd88 2ab11c82

+108 -40
+1 -3
.github/workflows/release.yml
··· 106 106 107 107 - name: Build 108 108 # Unreleased v1 is used to be able to use uploadWorkflowArtifacts and mobile: 109 - # uses: tauri-apps/tauri-action@a1b01e690374452a209dd44f6de089dda9003d3d 110 - # v1 bugfix by Fabian to fix multiple releases being created: 111 - uses: tauri-apps/tauri-action@ed2469ebf97aca60a033d455c4f2c28c65375fa3 109 + uses: tauri-apps/tauri-action@850c373b94f35e16e4de61cbb9f6e4876af8c0b4 112 110 env: 113 111 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 114 112 with:
+3
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + ## Next 4 + - Android: Add track streaming search links for Spotify and YouTube Music 5 + 3 6 ## 0.21.0 - 2026 Map 6 4 7 - Add a basic Android app. It only lets you browse a Ferrum Library.json file. No playback or syncing yet. 5 8
+1 -1
Cargo.lock
··· 1136 1136 1137 1137 [[package]] 1138 1138 name = "ferrum_mobile" 1139 - version = "0.0.0" 1139 + version = "0.0.1" 1140 1140 dependencies = [ 1141 1141 "android_logger", 1142 1142 "anyhow",
+2
mobile/src-tauri/Cargo.toml
··· 1 1 [package] 2 2 name = "ferrum_mobile" 3 + publish = false 3 4 edition = "2024" 4 5 default-run = "ferrum_mobile" 6 + version = "0.0.1" # overridden in ci build 5 7 6 8 # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 9
+5 -1
mobile/src-tauri/capabilities/default.json
··· 15 15 "store:default", 16 16 "safe-area-insets-css:default", 17 17 "android-fs:default", 18 - "os:default" 18 + "os:default", 19 + { 20 + "identifier": "opener:allow-open-url", 21 + "allow": [{ "url": "*" }] 22 + } 19 23 ] 20 24 }
-1
mobile/src-tauri/src/lib.rs
··· 21 21 .show(|_| {}); 22 22 } 23 23 24 - // you shoud use async fn 25 24 #[tauri::command] 26 25 #[specta::specta] 27 26 async fn open_file_persistent_android(app: tauri::AppHandle) -> Result<Option<String>, String> {
+39 -2
mobile/src/routes/+page.svelte
··· 6 6 import Library from './Library.svelte' 7 7 import { Store } from '@tauri-apps/plugin-store' 8 8 import { platform } from '@tauri-apps/plugin-os' 9 - import '@saurl/tauri-plugin-safe-area-insets-css-api' 9 + 10 + const platform_value = platform() 11 + if (platform_value === 'android' || platform_value === 'ios') { 12 + await import('@saurl/tauri-plugin-safe-area-insets-css-api') 13 + } 14 + 15 + type StreamingService = 'spotify' | 'youtube-music' 10 16 11 17 let loading = $state(false) 12 18 let error = $state('') 19 + let streaming_service = $state<StreamingService>('spotify') 13 20 14 21 const store = await Store.load('settings.json') 15 22 let library = $state<LibraryTauri | null>(null) 16 23 17 24 const saved_library_path = await store.get('library_path') 25 + const saved_streaming_service = await store.get('streaming_service') 26 + if (saved_streaming_service === 'spotify' || saved_streaming_service === 'youtube-music') { 27 + streaming_service = saved_streaming_service 28 + } 18 29 if (typeof saved_library_path === 'string') { 19 30 load_library(saved_library_path) 31 + } 32 + 33 + async function set_streaming_service(service: StreamingService) { 34 + streaming_service = service 35 + await store.set('streaming_service', service) 36 + await store.save() 20 37 } 21 38 22 39 async function open_library() { ··· 81 98 </button> 82 99 {/snippet} 83 100 101 + <div class="shrink-0 border-b border-neutral-200 px-4 py-2.5 dark:border-neutral-800"> 102 + <div class="flex items-center gap-2"> 103 + <label for="streaming-service" class="text-xs text-neutral-500">Streaming</label> 104 + <select 105 + id="streaming-service" 106 + class="rounded-md border border-neutral-300 bg-white px-2 py-1 text-xs text-neutral-700 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200" 107 + value={streaming_service} 108 + onchange={(event) => { 109 + const service = (event.currentTarget as HTMLSelectElement).value 110 + if (service === 'spotify' || service === 'youtube-music') { 111 + set_streaming_service(service) 112 + } 113 + }} 114 + > 115 + <option value="spotify">Spotify</option> 116 + <option value="youtube-music">YouTube Music</option> 117 + </select> 118 + </div> 119 + </div> 120 + 84 121 {#if library} 85 - <Library {library} {open_button} /> 122 + <Library {library} {open_button} {streaming_service} /> 86 123 {:else} 87 124 <div 88 125 class="flex flex-1 flex-col items-center justify-center gap-4 px-8 text-center text-neutral-400 dark:text-neutral-700"
+57 -32
mobile/src/routes/Library.svelte
··· 4 4 import { page } from '$app/state' 5 5 import type { Track, TrackList, Playlist, Folder, Special, LibraryTauri } from '../../bindings' 6 6 import { resolve } from '$app/paths' 7 + import { openUrl } from '@tauri-apps/plugin-opener' 7 8 8 9 type sort_key_type = 'name' | 'artist' | 'dateAdded' | 'playCount' 9 10 type sort_dir_type = 'asc' | 'desc' 10 11 type active_filter_type = { kind: 'all' } | { kind: 'liked' } | { kind: 'genre'; value: string } 11 12 type view_type = { kind: 'browser'; folder_id: string } | { kind: 'tracks'; playlist_id: string } 13 + type streaming_service_type = 'spotify' | 'youtube-music' 12 14 13 - const { library, open_button } = $props<{ 15 + const { library, open_button, streaming_service } = $props<{ 14 16 library: LibraryTauri | null 15 17 open_button: Snippet<[]> 18 + streaming_service: streaming_service_type 16 19 }>() 17 20 let error = $state('') 18 21 let search_query = $state('') ··· 178 181 function format_duration(seconds: number): string { 179 182 const s = Math.floor(seconds) 180 183 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}` 184 + } 185 + 186 + function open_track(track: Track) { 187 + const artist = track.artist?.trim() || 'Unknown Artist' 188 + const title = track.name.trim() 189 + const query = `${artist} - ${title}` 190 + const encoded_query = encodeURIComponent(query) 191 + if (streaming_service === 'youtube-music') { 192 + openUrl(`vnd.youtube.music://search?q=${encoded_query}`) 193 + } else if (streaming_service === 'spotify') { 194 + openUrl(`open.spotify.com/search/${encoded_query}`) 195 + } 181 196 } 182 197 </script> 183 198 ··· 439 454 {#if filtered_tracks.length > 0} 440 455 <ul class="divide-y divide-neutral-200 dark:divide-neutral-900"> 441 456 {#each filtered_tracks as track} 442 - <li class="flex items-center gap-3 px-4 py-3 transition-colors"> 443 - <div class="min-w-0 flex-1"> 444 - <p class="truncate font-medium text-neutral-900 dark:text-neutral-100"> 445 - {track.name} 446 - </p> 447 - <p class="mt-0.5 truncate text-xs text-neutral-500"> 448 - {track.artist ?? 'Unknown Artist'} 449 - {#if track.albumName} 450 - <span class="text-neutral-400 dark:text-neutral-700"> · </span>{track.albumName} 451 - {/if} 452 - </p> 453 - <div class="mt-1 flex items-center gap-2"> 454 - {#if track.genre} 455 - <span 456 - class="rounded bg-neutral-100 px-1.5 py-px text-xs text-neutral-500 dark:bg-neutral-800" 457 - >{track.genre}</span 458 - > 457 + <li> 458 + <button 459 + rel="noopener noreferrer" 460 + class="focus-visible active flex items-center justify-between gap-3 px-4 py-3 focus-visible:bg-neutral-100" 461 + onclick={() => open_track(track)} 462 + > 463 + <div> 464 + <p class="truncate font-medium text-neutral-900 dark:text-neutral-100"> 465 + {track.name} 466 + </p> 467 + <p class="mt-0.5 truncate text-xs text-neutral-500"> 468 + {track.artist ?? 'Unknown Artist'} 469 + {#if track.albumName} 470 + <span class="text-neutral-400 dark:text-neutral-700"> 471 + · 472 + </span>{track.albumName} 473 + {/if} 474 + </p> 475 + <div class="mt-1 flex items-center gap-2"> 476 + {#if track.genre} 477 + <span 478 + class="rounded bg-neutral-100 px-1.5 py-px text-xs text-neutral-500 dark:bg-neutral-800" 479 + >{track.genre}</span 480 + > 481 + {/if} 482 + {#if track.year} 483 + <span class="text-xs text-neutral-400 dark:text-neutral-700" 484 + >{track.year}</span 485 + > 486 + {/if} 487 + </div> 488 + </div> 489 + <div 490 + class="flex shrink-0 flex-col items-end gap-1 text-xs text-neutral-400 tabular-nums dark:text-neutral-600" 491 + > 492 + <span>{format_duration(track.duration)}</span> 493 + {#if track.playCount} 494 + <span>{track.playCount} plays</span> 459 495 {/if} 460 - {#if track.year} 461 - <span class="text-xs text-neutral-400 dark:text-neutral-700">{track.year}</span> 496 + {#if track.liked} 497 + <span class="text-rose-500">♥</span> 462 498 {/if} 463 499 </div> 464 - </div> 465 - <div 466 - class="flex shrink-0 flex-col items-end gap-1 text-xs text-neutral-400 tabular-nums dark:text-neutral-600" 467 - > 468 - <span>{format_duration(track.duration)}</span> 469 - {#if track.playCount} 470 - <span>{track.playCount} plays</span> 471 - {/if} 472 - {#if track.liked} 473 - <span class="text-rose-500">♥</span> 474 - {/if} 475 - </div> 500 + </button> 476 501 </li> 477 502 {/each} 478 503 </ul>