diff --git a/README.md b/README.md
index d80cd97..3fab889 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,7 @@ Head over to the [Releases](https://github.com/Geomitron/Bridge/releases) page t
- ✅ A variety of themes.
- ✅ Advanced song search.
- ✅ Chart issue scanner (for people making charts).
+- ✅ Playlist import and export.
### What's new in v3.4.0
diff --git a/package.json b/package.json
index 4dfd309..e3effea 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "bridge",
- "version": "3.4.5",
+ "version": "3.4.4",
"description": "A rhythm game chart searching and downloading tool.",
"homepage": "https://github.com/Geomitron/Bridge",
"license": "GPL-3.0",
diff --git a/src-angular/app/app-routing.module.ts b/src-angular/app/app-routing.module.ts
index c33b5cf..9acc324 100644
--- a/src-angular/app/app-routing.module.ts
+++ b/src-angular/app/app-routing.module.ts
@@ -5,12 +5,14 @@ import { BrowseComponent } from './components/browse/browse.component'
import { SettingsComponent } from './components/settings/settings.component'
import { ToolsComponent } from './components/tools/tools.component'
import { TabPersistStrategy } from './core/tab-persist.strategy'
+import { PlaylistComponent } from './components/playlist/playlist.component'
const routes: Routes = [
{ path: 'browse', component: BrowseComponent, data: { shouldReuse: true } },
{ path: 'library', redirectTo: '/browse' },
{ path: 'tools', component: ToolsComponent, data: { shouldReuse: true } },
{ path: 'settings', component: SettingsComponent, data: { shouldReuse: true } },
+ { path: 'playlist', component: PlaylistComponent, data: { shouldReuse: true } },
{ path: 'about', redirectTo: '/browse' },
{ path: '**', redirectTo: '/browse' },
]
diff --git a/src-angular/app/app.module.ts b/src-angular/app/app.module.ts
index 8511388..523eb0e 100644
--- a/src-angular/app/app.module.ts
+++ b/src-angular/app/app.module.ts
@@ -18,6 +18,9 @@ import { StatusBarComponent } from './components/browse/status-bar/status-bar.co
import { SettingsComponent } from './components/settings/settings.component'
import { ToolbarComponent } from './components/toolbar/toolbar.component'
import { RemoveStyleTagsPipe } from './core/pipes/remove-style-tags.pipe'
+import { PlaylistTableComponent } from './components/playlist/playlist-table/playlist-table.component'
+import { PlaylistComponent } from './components/playlist/playlist.component'
+import { PlaylistBarComponent } from './components/playlist/playlist-bar/playlist-bar.component'
@NgModule({
declarations: [
@@ -35,6 +38,9 @@ import { RemoveStyleTagsPipe } from './core/pipes/remove-style-tags.pipe'
DownloadsModalComponent,
RemoveStyleTagsPipe,
SettingsComponent,
+ PlaylistTableComponent,
+ PlaylistComponent,
+ PlaylistBarComponent,
],
bootstrap: [AppComponent], imports: [
BrowserModule,
diff --git a/src-angular/app/components/browse/chart-sidebar/chart-sidebar.component.ts b/src-angular/app/components/browse/chart-sidebar/chart-sidebar.component.ts
index 61a06c8..1cefc08 100644
--- a/src-angular/app/components/browse/chart-sidebar/chart-sidebar.component.ts
+++ b/src-angular/app/components/browse/chart-sidebar/chart-sidebar.component.ts
@@ -42,7 +42,7 @@ export class ChartSidebarComponent implements OnInit {
private renderer: Renderer2,
private searchService: SearchService,
private downloadService: DownloadService,
- public settingsService: SettingsService
+ public settingsService: SettingsService,
) { }
ngOnInit() {
diff --git a/src-angular/app/components/playlist/playlist-bar/playlist-bar.component.html b/src-angular/app/components/playlist/playlist-bar/playlist-bar.component.html
new file mode 100644
index 0000000..cf0f7b4
--- /dev/null
+++ b/src-angular/app/components/playlist/playlist-bar/playlist-bar.component.html
@@ -0,0 +1,53 @@
+
+
+ {{ (this.playlistService.tracks$ | async)?.length ?? 0 }} Songs
+
+ @if ((this.playlistService.selectedSongs$ | async)!.length > 0) {
+
+ } @else {
+
+ }
+
+
+
+ @if ((this.playlistService.selectedSongs$ | async)!.length === 0) {
+
+ } @else {
+
+ }
+
+
+
0" class="max-w-100 text-ellipsis text-nowrap overflow-hidden whitespace-nowrap flex items-center">
+ {{ downloadService.currentDownloadText }}
+
+
0" class="flex gap-2 items-center">
+
+
+
+
+
+
diff --git a/src-angular/app/components/playlist/playlist-bar/playlist-bar.component.ts b/src-angular/app/components/playlist/playlist-bar/playlist-bar.component.ts
new file mode 100644
index 0000000..83f3fc0
--- /dev/null
+++ b/src-angular/app/components/playlist/playlist-bar/playlist-bar.component.ts
@@ -0,0 +1,48 @@
+import { Component, ElementRef, ViewChild } from '@angular/core'
+import { PlaylistService } from 'src-angular/app/core/services/playlist.service'
+import { DownloadService } from '../../../core/services/download.service'
+
+@Component({
+ selector: 'app-playlist-bar',
+ templateUrl: './playlist-bar.component.html',
+ standalone: false,
+})
+export class PlaylistBarComponent {
+ @ViewChild('fileInput', { static: false }) fileInput: ElementRef
+
+ constructor(public playlistService: PlaylistService, public downloadService: DownloadService) { }
+
+ exportPlaylist() {
+ this.playlistService.storePlaylist()
+ }
+
+ exportSelected() {
+ this.playlistService.storeSelectedSongs()
+ }
+
+ importPlaylist() {
+ this.fileInput.nativeElement.click()
+ }
+
+ onFileSelected(event: Event) {
+ const input = event.target as HTMLInputElement
+
+ if (input.files && input.files.length > 0) {
+ const file = input.files[0]
+ const reader = new FileReader()
+ reader.onload = () => {
+ try {
+ const importedTracks = JSON.parse(reader.result as string)
+ if (Array.isArray(importedTracks)) {
+ this.playlistService.downloadPlaylist(importedTracks)
+ } else {
+ console.error('Invalid file format')
+ }
+ } catch (error) {
+ console.error('Error parsing file:', error)
+ }
+ }
+ reader.readAsText(file)
+ }
+ }
+}
diff --git a/src-angular/app/components/playlist/playlist-table/playlist-table.component.html b/src-angular/app/components/playlist/playlist-table/playlist-table.component.html
new file mode 100644
index 0000000..7c969d1
--- /dev/null
+++ b/src-angular/app/components/playlist/playlist-table/playlist-table.component.html
@@ -0,0 +1,67 @@
+
+
+
+ 0"
+ class="basis-2/3 flex-1 overflow-y-auto scrollbar scrollbar-w-2 scrollbar-h-2 scrollbar-track-base-300 scrollbar-thumb-neutral scrollbar-thumb-rounded-full h-[calc(100vh-164px)]">
+
+
+
+ 1 && filteredSongs.length < 1"
+ style="display: flex; justify-content: center; align-items: center">
+
No songs found!
+
+
+
diff --git a/src-angular/app/components/playlist/playlist-table/playlist-table.component.ts b/src-angular/app/components/playlist/playlist-table/playlist-table.component.ts
new file mode 100644
index 0000000..c082adc
--- /dev/null
+++ b/src-angular/app/components/playlist/playlist-table/playlist-table.component.ts
@@ -0,0 +1,127 @@
+import { Component, OnInit, OnDestroy } from '@angular/core'
+import { PlaylistService } from 'src-angular/app/core/services/playlist.service'
+import { SelectionService } from 'src-angular/app/core/services/selection.service'
+import { ChartData } from 'src-shared/interfaces/search.interface'
+import { SettingsService } from '../../../core/services/settings.service'
+import { Subscription } from 'rxjs'
+
+type SortColumn = 'name' | 'artist' | 'album' | 'genre' | 'year' | 'charter' | 'length' | 'modifiedTime' | null
+
+@Component({
+ selector: 'app-playlist-table',
+ templateUrl: './playlist-table.component.html',
+ standalone: false,
+})
+export class PlaylistTableComponent implements OnInit, OnDestroy {
+ songs: ChartData[] = []
+ sortDirection: 'asc' | 'desc' = 'asc'
+ sortColumn: SortColumn = null
+ filteredSongs: ChartData[] = []
+ searchTerm: string = ''
+ allRowsSelected: boolean = false
+ subscriptions: Subscription[] = []
+ selectedSongs: ChartData[] = []
+
+ constructor(
+ public playlistService: PlaylistService,
+ public settingsService: SettingsService,
+ private selectionService: SelectionService
+ ) { }
+
+ get allSelected() {
+ return this.selectionService.isAllSelected()
+ }
+
+ set allSelected(value: boolean) {
+ if (value) {
+ this.selectionService.selectAll()
+ } else {
+ this.selectionService.deselectAll()
+ }
+ }
+
+ ngOnInit(): void {
+ this.subscriptions.push(
+ this.playlistService.tracks$
+ .subscribe(tracks => {
+ this.songs = tracks
+ this.filterSongs()
+ })
+ )
+ this.filteredSongs = [...this.songs]
+ this.subscriptions.push(
+ this.playlistService.selectedSongs$.subscribe(songs =>
+ this.selectedSongs = songs
+ )
+ )
+ }
+
+ filterSongs(): void {
+ const term = this.searchTerm.toLowerCase()
+ this.filteredSongs = this.songs.filter(
+ song =>
+ song.name?.toLowerCase().includes(term) ||
+ song.artist?.toLowerCase().includes(term) ||
+ song.album?.toLowerCase().includes(term) ||
+ song.genre?.toLowerCase().includes(term) ||
+ song.year?.toLowerCase().includes(term) ||
+ song.charter?.toLowerCase().includes(term)
+ )
+ }
+
+ trackByFn(index: number): number {
+ return index
+ }
+
+ onColClicked(column: SortColumn) {
+ if (this.filteredSongs.length === 0) { return }
+
+ if (this.sortColumn !== column) {
+ this.sortColumn = column
+ this.sortDirection = 'asc'
+ } else if (this.sortDirection === 'asc') {
+ this.sortDirection = 'desc'
+ } else {
+ this.sortDirection = 'asc'
+ this.sortColumn = null
+ }
+
+ if (this.sortColumn) {
+ this.filteredSongs.sort((a, b) => {
+ const valueA = a[this.sortColumn! as keyof ChartData]
+ const valueB = b[this.sortColumn! as keyof ChartData]
+
+ if (valueA == null && valueB == null) return 0
+ if (valueA == null) return this.sortDirection === 'asc' ? -1 : 1
+ if (valueB == null) return this.sortDirection === 'asc' ? 1 : -1
+
+ if (valueA < valueB) return this.sortDirection === 'asc' ? -1 : 1
+ if (valueA > valueB) return this.sortDirection === 'asc' ? 1 : -1
+ return 0
+ })
+ }
+ }
+
+ onCheckboxChange(song: ChartData, target?: EventTarget): void {
+ const input = target as HTMLInputElement
+ if (input.checked) {
+ this.playlistService.addToSelectedSongs(song)
+ } else {
+ this.playlistService.removeFromSelectedSongs(song)
+ }
+ }
+
+ toggleSelectAll(): void {
+ this.allRowsSelected = !this.allRowsSelected
+
+ if (this.allRowsSelected) {
+ this.filteredSongs.forEach(song => this.playlistService.addToSelectedSongs(song))
+ } else {
+ this.playlistService.clearSelectedSongs()
+ }
+ }
+
+ ngOnDestroy(): void {
+ this.subscriptions.forEach(subscription => subscription.unsubscribe())
+ }
+}
diff --git a/src-angular/app/components/playlist/playlist.component.html b/src-angular/app/components/playlist/playlist.component.html
new file mode 100644
index 0000000..34282d6
--- /dev/null
+++ b/src-angular/app/components/playlist/playlist.component.html
@@ -0,0 +1,4 @@
+
+
diff --git a/src-angular/app/components/playlist/playlist.component.ts b/src-angular/app/components/playlist/playlist.component.ts
new file mode 100644
index 0000000..b938130
--- /dev/null
+++ b/src-angular/app/components/playlist/playlist.component.ts
@@ -0,0 +1,10 @@
+import { Component } from '@angular/core'
+
+@Component({
+ selector: 'app-playlist',
+ templateUrl: './playlist.component.html',
+ standalone: false,
+})
+export class PlaylistComponent {
+
+}
diff --git a/src-angular/app/components/toolbar/toolbar.component.html b/src-angular/app/components/toolbar/toolbar.component.html
index b70ab3f..f041f52 100644
--- a/src-angular/app/components/toolbar/toolbar.component.html
+++ b/src-angular/app/components/toolbar/toolbar.component.html
@@ -2,6 +2,7 @@
+