diff --git a/src/Application.vala b/src/Application.vala index 77ef68206..9928e41a9 100644 --- a/src/Application.vala +++ b/src/Application.vala @@ -149,6 +149,27 @@ public class Music.Application : Gtk.Application { continue; } + if (M3U.is_playlist (file)) { + + File[]? tracks = null; + try { + tracks = M3U.parse_playlist (file); + } catch (Error e) { + warning (e.message); + } + + if (tracks == null) { + continue; + } + + foreach (var track in tracks) { + elements += track; + } + + // Avoid adding the m3u - Else its content gets re-added every startup + continue; + } + elements += file; } diff --git a/src/MainWindow.vala b/src/MainWindow.vala index da40c5cd9..377382cb5 100644 --- a/src/MainWindow.vala +++ b/src/MainWindow.vala @@ -6,6 +6,7 @@ public class Music.MainWindow : Gtk.ApplicationWindow { private const string ACTION_PREFIX = "win."; private const string ACTION_OPEN = "action-open"; + private const string ACTION_SAVE_M3U_PLAYLIST = "action-save-m3u-playlist"; private Granite.Placeholder queue_placeholder; private Granite.Placeholder search_placeholder; @@ -18,9 +19,10 @@ public class Music.MainWindow : Gtk.ApplicationWindow { private Gtk.SingleSelection selection_model; private Gtk.Stack queue_stack; private Settings settings; + private unowned PlaybackManager playback_manager; construct { - var playback_manager = PlaybackManager.get_default (); + playback_manager = PlaybackManager.get_default (); var start_window_controls = new Gtk.WindowControls (Gtk.PackType.START); @@ -89,6 +91,9 @@ public class Music.MainWindow : Gtk.ApplicationWindow { var open_action = new SimpleAction (ACTION_OPEN, null); open_action.activate.connect (open_files); + var save_action = new SimpleAction (ACTION_SAVE_M3U_PLAYLIST, null); + save_action.activate.connect (action_save_m3u_playlist); + var add_button = new Gtk.Button () { child = add_button_box, action_name = ACTION_PREFIX + ACTION_OPEN @@ -185,9 +190,11 @@ public class Music.MainWindow : Gtk.ApplicationWindow { unowned var app = ((Gtk.Application) GLib.Application.get_default ()); app.set_accels_for_action (ACTION_PREFIX + ACTION_OPEN, {"O"}); - add_action (open_action); + app.set_accels_for_action (ACTION_PREFIX + ACTION_SAVE_M3U_PLAYLIST, {"S"}); + add_action (save_action); + drop_target.drop.connect ((target, value, x, y) => { if (value.type () == typeof (Gdk.FileList)) { var list = (Gdk.FileList)value; @@ -278,9 +285,15 @@ public class Music.MainWindow : Gtk.ApplicationWindow { }; music_files_filter.add_mime_type ("audio/*"); + var playlist_filter = new Gtk.FileFilter () { + name = _("M3U Playlists"), + }; + playlist_filter.add_mime_type ("audio/x-mpegurl"); + var filter_model = new ListStore (typeof (Gtk.FileFilter)); filter_model.append (all_files_filter); filter_model.append (music_files_filter); + filter_model.append (playlist_filter); var file_dialog = new Gtk.FileDialog () { accept_label = _("Open"), @@ -321,6 +334,57 @@ public class Music.MainWindow : Gtk.ApplicationWindow { }); } + public void action_save_m3u_playlist () { + var all_files_filter = new Gtk.FileFilter () { + name = _("All files"), + }; + all_files_filter.add_pattern ("*"); + + var playlist_filter = new Gtk.FileFilter () { + name = _("M3U Playlists"), + }; + playlist_filter.add_mime_type ("audio/x-mpegurl"); + + var filter_model = new ListStore (typeof (Gtk.FileFilter)); + filter_model.append (all_files_filter); + filter_model.append (playlist_filter); + + var save_dialog = new Gtk.FileDialog () { + accept_label = _("Save"), + default_filter = playlist_filter, + filters = filter_model, + modal = true, + title = _("Save playlist"), + initial_name = "%s.m3u".printf (_("New playlist")) + }; + + save_dialog.save.begin (this, null, (obj, res) => { + File? file; + try { + file = save_dialog.save.end (res); + M3U.save_playlist (playback_manager.queue_liststore, file); + } catch (Error err) { + if (err.matches (Gtk.DialogError.quark (), Gtk.DialogError.DISMISSED)) { + return; + } + + warning ("Failed to save playlist: %s", err.message); + + var dialog = new Granite.MessageDialog ( + _("Couldn't save playlist"), + err.message, + new ThemedIcon ("audio-x-playlist") + ) { + badge_icon = new ThemedIcon ("dialog-error"), + modal = true, + transient_for = this + }; + dialog.present (); + dialog.response.connect (dialog.destroy); + } + }); + } + private void update_repeat_button () { switch (settings.get_string ("repeat-mode")) { case "disabled": diff --git a/src/Services/M3U.vala b/src/Services/M3U.vala new file mode 100644 index 000000000..10ce34cab --- /dev/null +++ b/src/Services/M3U.vala @@ -0,0 +1,77 @@ +/* + * SPDX-License-Identifier: LGPL-3.0-or-later + * SPDX-FileCopyrightText: 2025 elementary, Inc. (https://elementary.io) + */ + +namespace Music.M3U { + public bool is_playlist (File file) { + FileInfo info; + + try { + info = file.query_info (GLib.FileAttribute.STANDARD_CONTENT_TYPE, FileQueryInfoFlags.NONE); + } catch (Error e) { + warning (e.message); + return false; + } + + var mimetype = info.get_content_type (); + if (mimetype == null) { + warning ("Failed to get content type"); + return false; + } + + return mimetype == "audio/x-mpegurl"; + } + + // Standard specification here: https://en.wikipedia.org/wiki/M3U + public File[]? parse_playlist (File playlist) throws Error { + debug ("Parsing playlist: %s", playlist.get_path ()); + File[] list = {}; + + FileInputStream @is = playlist.read (); + DataInputStream dis = new DataInputStream (@is); + string line; + + while ((line = dis.read_line ()) != null) { + // Skip extended + if (line.has_prefix ("#EXT")) { + debug ("Skipping EXTM3U: " + line); + continue; + } + + // Skip URL + if (line.ascii_down ().has_prefix ("http")) { + debug ("Skipping URL: " + line); + continue; + } + + File target; + + if (line.ascii_down ().has_prefix ("file:///")) { + target = File.new_for_uri (line); + } else { + target = File.new_for_path (line); + } + + // The caller is responsible for testing whether files exist and + // are valid using PlaybackManager.queue_files() instead of here + list += target; + } + + return list; + } + + public void save_playlist (ListStore queue_liststore, File playlist) throws Error { + debug ("Saving queue as playlist"); + string content = ""; + + for (var i = 0; i < queue_liststore.n_items; i++) { + var item = (Music.AudioObject)queue_liststore.get_item (i); + content = content + item.uri + "\n"; + } + + var ostream = playlist.replace (null, false, GLib.FileCreateFlags.REPLACE_DESTINATION); + var dostream = new DataOutputStream (ostream); + dostream.put_string (content); + } +} diff --git a/src/meson.build b/src/meson.build index a2eef2584..deae423dd 100644 --- a/src/meson.build +++ b/src/meson.build @@ -10,6 +10,7 @@ sources = [ 'Widgets/SearchBar.vala', 'Widgets/SeekBar.vala', 'Widgets/TrackRow.vala', + 'Services/M3U.vala', ] executable(