Jelajahi Sumber

Merge pull request #52 from coolerUA/feat/sub-ghz-playlist-creator

Add Sub-Ghz Playlist Creator
WillyJL 7 bulan lalu
induk
melakukan
6582ac3b6f

+ 7 - 0
subghz_playlist_creator/.gitignore

@@ -0,0 +1,7 @@
+dist/*
+.vscode
+.clang-format
+.clangd
+.editorconfig
+.env
+.ufbt

+ 1 - 0
subghz_playlist_creator/.gitsubtree

@@ -0,0 +1 @@
+https://github.com/coolerUA/Sub-Ghz-Playlist-Creator main /

+ 21 - 0
subghz_playlist_creator/LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 coolerUA
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 42 - 0
subghz_playlist_creator/README.md

@@ -0,0 +1,42 @@
+# SubGhz Playlist Creator
+
+A Flipper Zero application for creating and managing playlists of .sub files.
+
+## Features
+
+- Create new playlists
+- Edit existing playlists
+- Add .sub files to playlists
+- Simple text-based playlist format
+
+## Installation
+
+1. Copy the application files to your Flipper Zero's applications folder
+2. Compile the application using the Flipper Zero firmware build system
+3. Install the compiled application on your Flipper Zero
+
+## Usage
+
+1. Launch the application from the Flipper Zero's applications menu
+2. Choose between creating a new playlist or editing an existing one
+3. For new playlists:
+   - Enter a name for your playlist
+   - Use the file browser to select .sub files to add
+   - Press back to finish adding files
+4. For existing playlists:
+   - Select the playlist you want to edit
+   - Add or remove files as needed
+
+## Playlist Format
+
+Playlists are stored as simple text files in the `/ext/subghz/playlists` directory. Each line in the playlist file contains the full path to a .sub file.
+
+## Notes
+
+- Playlists are stored in `/ext/subghz/playlists` directory
+- Each playlist is saved as a .txt file
+- The application only works with .sub files
+- Make sure your SD card has enough space for the playlists 
+
+
+All code generated by AI.

+ 14 - 0
subghz_playlist_creator/application.fam

@@ -0,0 +1,14 @@
+App(
+    appid="subghz_playlist_creator",
+    name="Sub-GHz Playlist Creator",
+    apptype=FlipperAppType.EXTERNAL,
+    entry_point="subghz_playlist_creator_app",
+    stack_size=2 * 1024,
+    fap_category="Sub-GHz",
+    fap_icon="subghz_playlist_creator.png",
+    fap_description="App for creating or editing SubGhz playlists",
+    fap_author="coolerUA",
+    fap_weburl="https://github.com/coolerUA/Sub-Ghz-Playlist-Creator",
+    fap_icon_assets="images",  
+)
+

+ 0 - 0
subghz_playlist_creator/images/.gitkeep


+ 32 - 0
subghz_playlist_creator/scenes/scene_dialog.c

@@ -0,0 +1,32 @@
+#include "../subghz_playlist_creator.h"
+#include "scene_dialog.h"
+#include <gui/modules/dialog_ex.h>
+
+void scene_dialog_show_custom(
+    SubGhzPlaylistCreator* app,
+    const char* header,
+    const char* text,
+    const char* left_btn,
+    const char* right_btn,
+    DialogExResultCallback callback,
+    void* context
+) {
+    app->current_view = SubGhzPlaylistCreatorViewDialog;
+    dialog_ex_set_header(app->dialog, header, 64, 0, AlignCenter, AlignTop);
+    dialog_ex_set_text(app->dialog, text, 64, 12, AlignCenter, AlignTop);
+    dialog_ex_set_icon(app->dialog, 0, 0, NULL);
+    dialog_ex_set_left_button_text(app->dialog, left_btn);
+    dialog_ex_set_right_button_text(app->dialog, right_btn);
+    dialog_ex_set_context(app->dialog, context);
+    dialog_ex_set_result_callback(app->dialog, callback);
+    view_dispatcher_switch_to_view(app->view_dispatcher, SubGhzPlaylistCreatorViewDialog);
+}
+
+void scene_dialog_show(SubGhzPlaylistCreator* app) {
+    app->current_view = SubGhzPlaylistCreatorViewDialog;
+    view_dispatcher_switch_to_view(app->view_dispatcher, SubGhzPlaylistCreatorViewDialog);
+}
+
+void scene_dialog_init_view(SubGhzPlaylistCreator* app) {
+    UNUSED(app);
+} 

+ 15 - 0
subghz_playlist_creator/scenes/scene_dialog.h

@@ -0,0 +1,15 @@
+#pragma once
+#include "../subghz_playlist_creator.h"
+#include <gui/modules/dialog_ex.h>
+
+void scene_dialog_show(SubGhzPlaylistCreator* app);
+void scene_dialog_init_view(SubGhzPlaylistCreator* app);
+void scene_dialog_show_custom(
+    SubGhzPlaylistCreator* app,
+    const char* header,
+    const char* text,
+    const char* left_btn,
+    const char* right_btn,
+    DialogExResultCallback callback,
+    void* context
+); 

+ 63 - 0
subghz_playlist_creator/scenes/scene_file_browser.c

@@ -0,0 +1,63 @@
+#include "../subghz_playlist_creator.h"
+#include "scene_file_browser.h"
+#include "scene_menu.h"
+#include "scene_text_input.h"
+#include <furi.h>
+#include <gui/view_dispatcher.h>
+
+// Remove global statics
+typedef struct {
+    SceneFileBrowserSelectCallback on_select;
+} SceneFileBrowserContext;
+
+static void file_browser_scene_callback(void* context) {
+    SubGhzPlaylistCreator* app = context;
+    if(furi_string_size(app->file_browser_result) > 0 && app->file_browser_select_cb) {
+        app->file_browser_select_cb(app, furi_string_get_cstr(app->file_browser_result));
+        app->file_browser_select_cb = NULL;
+    } else {
+        if(app->return_scene == ReturnScene_PlaylistEdit) {
+            scene_playlist_edit_show(app);
+        } else if(app->return_scene == ReturnScene_Menu) {
+            scene_menu_show(app);
+        } else if(app->return_scene == ReturnScene_TextInput) {
+            scene_text_input_show(app);
+        } else {
+            scene_menu_show(app);
+        }
+    }
+}
+
+void scene_file_browser_select(
+    SubGhzPlaylistCreator* app,
+    const char* start_dir,
+    const char* extension,
+    SceneFileBrowserSelectCallback on_select
+) {
+    app->file_browser_select_cb = on_select;
+    furi_string_set(app->file_browser_result, start_dir);
+    file_browser_configure(
+        app->file_browser,
+        extension,
+        start_dir,
+        false,  // skip_assets
+        true,   // hide_dot_files
+        NULL,   // file_icon
+        false   // hide_ext
+    );
+    file_browser_set_callback(app->file_browser, file_browser_scene_callback, app);
+    file_browser_start(app->file_browser, app->file_browser_result);
+    app->current_view = SubGhzPlaylistCreatorViewFileBrowser;
+    view_dispatcher_switch_to_view(app->view_dispatcher, SubGhzPlaylistCreatorViewFileBrowser);
+}
+
+void scene_file_browser_show(SubGhzPlaylistCreator* app) {
+    view_dispatcher_switch_to_view(app->view_dispatcher, SubGhzPlaylistCreatorViewFileBrowser);
+}
+
+// Add the definition for scene_file_browser_init_view
+void scene_file_browser_init_view(SubGhzPlaylistCreator* app) {
+    // The view is allocated in subghz_playlist_creator_alloc and added to the dispatcher there.
+    // This function can remain empty for now.
+    UNUSED(app);
+} 

+ 19 - 0
subghz_playlist_creator/scenes/scene_file_browser.h

@@ -0,0 +1,19 @@
+#pragma once
+
+typedef struct SubGhzPlaylistCreator SubGhzPlaylistCreator;
+
+// Callback type for file selection
+typedef void (*SceneFileBrowserSelectCallback)(SubGhzPlaylistCreator* app, const char* path);
+
+// Launch file browser scene, call on_select when a file is selected
+void scene_file_browser_select(
+    SubGhzPlaylistCreator* app,
+    const char* start_dir,
+    const char* extension,
+    SceneFileBrowserSelectCallback on_select
+);
+
+void scene_file_browser_show(SubGhzPlaylistCreator* app);
+
+// Add missing init view function declaration
+void scene_file_browser_init_view(SubGhzPlaylistCreator* app); 

+ 17 - 0
subghz_playlist_creator/scenes/scene_menu.c

@@ -0,0 +1,17 @@
+#include "../subghz_playlist_creator.h"
+#include "scene_menu.h"
+#define TAG "PlaylistMenuScene"
+
+void scene_menu_show(SubGhzPlaylistCreator* app) {
+    app->current_view = SubGhzPlaylistCreatorViewSubmenu;
+    FURI_LOG_D(TAG, "Showing menu view. Dispatcher: %p, Submenu View: %p, ViewId: %lu", app->view_dispatcher, submenu_get_view(app->submenu), (uint32_t)SubGhzPlaylistCreatorViewSubmenu);
+    view_dispatcher_switch_to_view(app->view_dispatcher, SubGhzPlaylistCreatorViewSubmenu);
+}
+
+// Add the definition for scene_menu_init_view
+void scene_menu_init_view(SubGhzPlaylistCreator* app) {
+    // The view (submenu in this case) is allocated in subghz_playlist_creator_alloc
+    // And added to the dispatcher there.
+    // This function can remain empty for now if allocation and adding are done elsewhere.
+    UNUSED(app);
+} 

+ 4 - 0
subghz_playlist_creator/scenes/scene_menu.h

@@ -0,0 +1,4 @@
+#pragma once
+#include "../subghz_playlist_creator.h"
+void scene_menu_show(SubGhzPlaylistCreator* app);
+void scene_menu_init_view(SubGhzPlaylistCreator* app); 

+ 203 - 0
subghz_playlist_creator/scenes/scene_playlist_edit.c

@@ -0,0 +1,203 @@
+#include "../subghz_playlist_creator.h"
+#include "scene_playlist_edit.h"
+#include "scene_menu.h"
+#include "scene_file_browser.h"
+#include "scene_popup.h"
+#include "scene_text_input.h"
+#include <furi.h>
+#include <gui/modules/file_browser.h>
+#include <gui/modules/widget.h>
+#include <storage/storage.h>
+#include <string.h>
+#include <gui/modules/dialog_ex.h>
+#include <gui/view.h>
+#include "scene_dialog.h"
+#include <gui/modules/submenu.h>
+#include <furi_hal.h>
+
+
+#define MAX_PLAYLIST_LINES 128
+#define MAX_FILENAME_LENGTH 128
+#define SUBGHZ_DIRECTORY "/ext/subghz"
+#define TAG "PlaylistEditScene"
+
+// Dialog type for PlaylistEdit
+typedef enum {
+    PlaylistEditDialog_None = 0,
+    PlaylistEditDialog_Discard,
+    PlaylistEditDialog_Save,
+    PlaylistEditDialog_Delete,
+} PlaylistEditDialogType;
+
+static PlaylistEditDialogType playlist_edit_dialog_type = PlaylistEditDialog_None;
+static uint32_t playlist_edit_selected_index = 0;
+
+// Add a static flag to indicate we should show menu after popup
+static bool playlist_edit_show_menu_after_popup = false;
+
+// Helper to read a line from file (since storage_file_read_line is not available)
+// static bool file_read_line(File* file, char* buffer, size_t max_len) { ... }
+
+// Dialog callback
+static void edit_dialog_callback(DialogExResult result, void* context) {
+    SubGhzPlaylistCreator* app = context;
+    FURI_LOG_D(TAG, "edit_dialog_callback: dialog_type=%d, result=%d", (int)playlist_edit_dialog_type, (int)result);
+    if(playlist_edit_dialog_type == PlaylistEditDialog_Discard) {
+        if(result == DialogExResultLeft) {
+            FURI_LOG_D(TAG, "Discard: DialogExResultLeft -> menu");
+            app->playlist_modified = false;
+            scene_menu_show(app);
+        } else if(result == DialogExResultRight) {
+            FURI_LOG_D(TAG, "Discard: DialogExResultRight -> stay in edit");
+            scene_playlist_edit_show(app);
+        } else {
+            FURI_LOG_D(TAG, "Discard: Unknown result -> stay in edit");
+            scene_playlist_edit_show(app);
+        }
+    } else if(playlist_edit_dialog_type == PlaylistEditDialog_Save) {
+        if(result == DialogExResultRight) {
+            FURI_LOG_D(TAG, "Save: DialogExResultRight -> save and menu");
+            File* file = storage_file_alloc(app->storage);
+            if(file && storage_file_open(file, furi_string_get_cstr(app->playlist_path), FSAM_WRITE, FSOM_CREATE_ALWAYS)) {
+                for(size_t i = 0; i < app->playlist_entry_count; ++i) {
+                    storage_file_write(file, "sub: ", 5);
+                    storage_file_write(file, app->playlist_entries[i], strlen(app->playlist_entries[i]));
+                    storage_file_write(file, "\n", 1);
+                }
+                storage_file_close(file);
+            }
+            if(file) storage_file_free(file);
+            app->playlist_modified = false;
+            for(size_t i = 0; i < app->playlist_entry_count; ++i) {
+                free(app->playlist_entries[i]);
+            }
+            free(app->playlist_entries);
+            app->playlist_entries = NULL;
+            app->playlist_entry_count = 0;
+            app->playlist_entry_capacity = 0;
+            playlist_edit_show_menu_after_popup = true;
+            scene_popup_show(app, "Success", "Playlist saved!");
+            return;
+        } else if(result == DialogExResultLeft) {
+            FURI_LOG_D(TAG, "Save: DialogExResultLeft -> stay in edit");
+            scene_playlist_edit_show(app);
+        } else {
+            FURI_LOG_D(TAG, "Save: Unknown result -> stay in edit");
+            scene_playlist_edit_show(app);
+        }
+    } else if(playlist_edit_dialog_type == PlaylistEditDialog_Delete) {
+        if(result == DialogExResultRight) {
+            FURI_LOG_D(TAG, "Delete: DialogExResultRight -> remove entry");
+            if(playlist_edit_selected_index < app->playlist_entry_count) {
+                free(app->playlist_entries[playlist_edit_selected_index]);
+                for(size_t i = playlist_edit_selected_index; i + 1 < app->playlist_entry_count; ++i) {
+                    app->playlist_entries[i] = app->playlist_entries[i + 1];
+                }
+                app->playlist_entry_count--;
+                app->playlist_modified = true;
+                if(playlist_edit_selected_index >= app->playlist_entry_count) playlist_edit_selected_index = app->playlist_entry_count ? app->playlist_entry_count - 1 : 0;
+            }
+            scene_playlist_edit_show(app);
+        } else {
+            FURI_LOG_D(TAG, "Delete: Not right -> stay in edit");
+            scene_playlist_edit_show(app);
+        }
+    }
+    playlist_edit_dialog_type = PlaylistEditDialog_None;
+}
+
+// Move the full definition of on_add_file_selected here:
+static void on_add_file_selected(SubGhzPlaylistCreator* app, const char* path) {
+    if(path && strlen(path)) {
+        if(app->playlist_entry_count == app->playlist_entry_capacity) {
+            app->playlist_entry_capacity = app->playlist_entry_capacity ? app->playlist_entry_capacity * 2 : 8;
+            app->playlist_entries = realloc(app->playlist_entries, app->playlist_entry_capacity * sizeof(char*));
+        }
+        app->playlist_entries[app->playlist_entry_count++] = strdup(path);
+        app->playlist_modified = true;
+    }
+    scene_playlist_edit_show(app);
+}
+
+// Move the full definition of playlist_edit_submenu_callback here:
+static void playlist_edit_submenu_callback(void* context, uint32_t index) {
+    SubGhzPlaylistCreator* app = context;
+    FURI_LOG_D(TAG, "playlist_edit_submenu_callback: index=%u, entry_count=%u", (unsigned int)index, (unsigned int)app->playlist_entry_count);
+    if(index == app->playlist_entry_count) {
+        FURI_LOG_D(TAG, "[+] Add file selected");
+        app->return_scene = ReturnScene_PlaylistEdit;
+        scene_file_browser_select(app, SUBGHZ_DIRECTORY, ".sub", on_add_file_selected);
+    } else if(index == app->playlist_entry_count + 1) {
+        FURI_LOG_D(TAG, "Save playlist selected, showing dialog");
+        scene_dialog_show_custom(
+            app,
+            "Save playlist?",
+            "Are you sure you want to save playlist?",
+            "Cancel",
+            "Save",
+            edit_dialog_callback,
+            app
+        );
+        playlist_edit_dialog_type = PlaylistEditDialog_Save;
+    } else {
+        FURI_LOG_D(TAG, "Entry selected for delete, showing dialog");
+        scene_dialog_show_custom(
+            app,
+            "Delete entry?",
+            "Remove file from playlist?",
+            "Cancel",
+            "Delete",
+            edit_dialog_callback,
+            app
+        );
+        playlist_edit_dialog_type = PlaylistEditDialog_Delete;
+    }
+}
+
+// Show PlaylistEdit scene using SubMenu
+void scene_playlist_edit_show(SubGhzPlaylistCreator* app) {
+    if(playlist_edit_show_menu_after_popup) {
+        playlist_edit_show_menu_after_popup = false;
+        scene_menu_show(app);
+        return;
+    }
+    app->current_view = SubGhzPlaylistCreatorViewPlaylistEdit;
+    submenu_reset(app->playlist_edit_submenu);
+    // Set header to playlist name with extension
+    submenu_set_header(app->playlist_edit_submenu, furi_string_get_cstr(app->playlist_name));
+    // Add playlist entries
+    for(size_t i = 0; i < app->playlist_entry_count; ++i) {
+        const char* fname = strrchr(app->playlist_entries[i], '/');
+        fname = fname ? fname + 1 : app->playlist_entries[i];
+        submenu_add_item(app->playlist_edit_submenu, fname, i, playlist_edit_submenu_callback, app);
+    }
+    // Add '[+] Add file' as next-to-last item
+    submenu_add_item(app->playlist_edit_submenu, "[+] Add file", app->playlist_entry_count, playlist_edit_submenu_callback, app);
+    // Add 'Save playlist' as last item
+    submenu_add_item(app->playlist_edit_submenu, "[s] Save playlist", app->playlist_entry_count + 1, playlist_edit_submenu_callback, app);
+    view_dispatcher_switch_to_view(app->view_dispatcher, SubGhzPlaylistCreatorViewPlaylistEdit);
+}
+
+void scene_playlist_edit_init_view(SubGhzPlaylistCreator* app) {
+    // No need to allocate VariableItemList, use SubMenu
+    // SubMenu is already allocated in app->playlist_edit_submenu
+    // Just ensure view is added for PlaylistEdit (done in app alloc)
+    UNUSED(app);
+}
+
+// Custom back event handler for PlaylistEdit
+bool scene_playlist_edit_back_event_callback(void* context) {
+    SubGhzPlaylistCreator* app = context;
+    // Show discard dialog instead of going to main menu
+    scene_dialog_show_custom(
+        app,
+        "Discard changes?",
+        "Are you sure you want to discard playlist?",
+        "Discard",
+        "Keep",
+        edit_dialog_callback,
+        app
+    );
+    playlist_edit_dialog_type = PlaylistEditDialog_Discard;
+    return false; // Prevent view dispatcher from popping the view
+}

+ 6 - 0
subghz_playlist_creator/scenes/scene_playlist_edit.h

@@ -0,0 +1,6 @@
+#pragma once
+#include "../subghz_playlist_creator.h"
+
+void scene_playlist_edit_init_view(SubGhzPlaylistCreator* app);
+
+// ... existing code ... 

+ 12 - 0
subghz_playlist_creator/scenes/scene_popup.c

@@ -0,0 +1,12 @@
+#include "../subghz_playlist_creator.h"
+#include "scene_popup.h"
+void scene_popup_show(SubGhzPlaylistCreator* app, const char* header, const char* text) {
+    app->current_view = SubGhzPlaylistCreatorViewPopup;
+    popup_set_header(app->popup, header, 64, 0, AlignCenter, AlignTop);
+    popup_set_text(app->popup, text, 64, 32, AlignCenter, AlignCenter);
+    popup_set_callback(app->popup, NULL);
+    popup_set_context(app->popup, NULL);
+    popup_set_timeout(app->popup, 2000);
+    popup_enable_timeout(app->popup);
+    view_dispatcher_switch_to_view(app->view_dispatcher, SubGhzPlaylistCreatorViewPopup);
+} 

+ 3 - 0
subghz_playlist_creator/scenes/scene_popup.h

@@ -0,0 +1,3 @@
+#pragma once
+#include "../subghz_playlist_creator.h"
+void scene_popup_show(SubGhzPlaylistCreator* app, const char* header, const char* text); 

+ 13 - 0
subghz_playlist_creator/scenes/scene_text_input.c

@@ -0,0 +1,13 @@
+#include "../subghz_playlist_creator.h"
+#include "scene_text_input.h"
+void scene_text_input_show(SubGhzPlaylistCreator* app) {
+    app->current_view = SubGhzPlaylistCreatorViewTextInput;
+    memset(app->text_buffer, 0, MAX_TEXT_LENGTH);
+    view_dispatcher_switch_to_view(app->view_dispatcher, SubGhzPlaylistCreatorViewTextInput);
+}
+
+// Add the definition for scene_text_input_init_view
+void scene_text_input_init_view(SubGhzPlaylistCreator* app) {
+    // The view is allocated in subghz_playlist_creator_alloc and added to the dispatcher there.
+    UNUSED(app);
+} 

+ 4 - 0
subghz_playlist_creator/scenes/scene_text_input.h

@@ -0,0 +1,4 @@
+#pragma once
+#include "../subghz_playlist_creator.h"
+void scene_text_input_show(SubGhzPlaylistCreator* app);
+void scene_text_input_init_view(SubGhzPlaylistCreator* app); 

+ 402 - 0
subghz_playlist_creator/subghz_playlist_creator.c

@@ -0,0 +1,402 @@
+#include "subghz_playlist_creator.h"
+#include "scenes/scene_menu.h"
+#include "scenes/scene_popup.h"
+#include "scenes/scene_text_input.h"
+#include "scenes/scene_dialog.h"
+#include "scenes/scene_file_browser.h"
+#include "scenes/scene_playlist_edit.h"
+#include <furi_hal.h>
+#include <input/input.h>
+#include <gui/view.h>
+#include <gui/view_dispatcher.h>
+#include <gui/modules/submenu.h>
+#include <gui/modules/variable_item_list.h>
+
+/* Logging */
+#include <furi_hal.h>
+#define TAG "PlaylistCreatorApp"
+
+/* generated by fbt from .png files in images folder */
+#include <subghz_playlist_creator_icons.h>
+
+#define POPUP_DISPLAY_TIME 2000 // 2 seconds in milliseconds
+#define PLAYLIST_EXTENSION ".txt"
+#define PLAYLIST_DIRECTORY "/ext/subghz/playlist"
+#define MAX_TEXT_LENGTH 128
+
+// Forward declarations
+static void create_playlist_file(SubGhzPlaylistCreator* app);
+static void subghz_playlist_creator_dialog_callback(DialogExResult result, void* context);
+
+// Replace the back event callback
+typedef enum {
+    BackEventTypeShort,
+    BackEventTypeLong,
+} BackEventType;
+
+// Custom navigation event callback
+typedef struct {
+    SubGhzPlaylistCreator* app;
+    ViewDispatcher* dispatcher;
+} BackEventContext;
+
+// Add forward declaration for custom back event handler
+bool scene_playlist_edit_back_event_callback(void* context);
+
+// Helper to read a line from file (since storage_file_read_line is not available)
+static bool file_read_line(File* file, char* buffer, size_t max_len) {
+    size_t i = 0;
+    char c = 0;
+    while(i + 1 < max_len) {
+        if(storage_file_read(file, &c, 1) != 1) break;
+        if(c == '\n') break;
+        buffer[i++] = c;
+    }
+    buffer[i] = 0;
+    return (i > 0) || (c == '\n');
+}
+
+static void show_popup(SubGhzPlaylistCreator* app, const char* header, const char* text) {
+    scene_popup_show(app, header, text);
+}
+
+static void create_playlist_file(SubGhzPlaylistCreator* app) {
+    if(!storage_simply_mkdir(app->storage, PLAYLIST_DIRECTORY)) {
+        show_popup(app, "Error", "Failed to create directory");
+        scene_menu_show(app);
+        return;
+    }
+    File* file = storage_file_alloc(app->storage);
+    if(!file) {
+        show_popup(app, "Error", "Failed to alloc file");
+        scene_menu_show(app);
+        return;
+    }
+    if(storage_file_open(file, furi_string_get_cstr(app->playlist_path), FSAM_WRITE, FSOM_CREATE_ALWAYS)) {
+        const char* header = "# SubGhz Playlist\n";
+        if(storage_file_write(file, header, strlen(header)) == strlen(header)) {
+            storage_file_close(file);
+            show_popup(app, "Success", "File created!");
+            scene_playlist_edit_show(app);
+            storage_file_free(file);
+            return;
+        } else {
+            storage_file_close(file);
+            show_popup(app, "Error", "Failed to write file");
+        }
+    } else {
+        show_popup(app, "Error", "Failed to open file");
+    }
+    storage_file_free(file);
+    scene_menu_show(app);
+}
+
+static void subghz_playlist_creator_dialog_callback(DialogExResult result, void* context) {
+    SubGhzPlaylistCreator* app = context;
+    if(result == DialogExResultRight) {
+        create_playlist_file(app);
+    } else {
+        scene_text_input_show(app);
+    }
+}
+
+// Callback for file selection from Edit
+static void on_edit_file_selected(SubGhzPlaylistCreator* app, const char* path) {
+    furi_string_set_str(app->playlist_path, path);
+    const char* filename = strrchr(path, '/');
+    if(filename) {
+        filename++;
+        furi_string_set_str(app->playlist_name, filename);
+    }
+    // Clear previous playlist state
+    if(app->playlist_entries) {
+        for(size_t i = 0; i < app->playlist_entry_count; ++i) {
+            free(app->playlist_entries[i]);
+        }
+        free(app->playlist_entries);
+        app->playlist_entries = NULL;
+        app->playlist_entry_count = 0;
+        app->playlist_entry_capacity = 0;
+    }
+    // Open and parse the playlist file
+    File* file = storage_file_alloc(app->storage);
+    if(file && storage_file_open(file, path, FSAM_READ, FSOM_OPEN_EXISTING)) {
+        char line[256];
+        while(file_read_line(file, line, sizeof(line))) {
+            // Ignore lines starting with '#'
+            if(line[0] == '#') continue;
+            // Only accept lines starting with 'sub: '
+            if(strncmp(line, "sub: ", 5) == 0) {
+                char* entry_path = line + 5;
+                // Remove trailing newline (already handled by file_read_line)
+                char* nl = strchr(entry_path, '\n');
+                if(nl) *nl = 0;
+                // Add to playlist state
+                if(app->playlist_entry_count == app->playlist_entry_capacity) {
+                    app->playlist_entry_capacity = app->playlist_entry_capacity ? app->playlist_entry_capacity * 2 : 8;
+                    app->playlist_entries = realloc(app->playlist_entries, app->playlist_entry_capacity * sizeof(char*));
+                }
+                app->playlist_entries[app->playlist_entry_count++] = strdup(entry_path);
+            }
+        }
+        storage_file_close(file);
+    }
+    if(file) storage_file_free(file);
+    // Open the PlaylistEdit scene for the selected playlist
+    scene_playlist_edit_show(app);
+}
+
+static void subghz_playlist_creator_submenu_callback(void* context, uint32_t index) {
+    SubGhzPlaylistCreator* app = context;
+    if(index == 2) { // Exit
+        view_dispatcher_stop(app->view_dispatcher);
+    } else if(index == 0) { // Create
+        memset(app->text_buffer, 0, MAX_TEXT_LENGTH);
+        scene_text_input_show(app);
+    } else if(index == 1) { // Edit
+        scene_file_browser_select(app, PLAYLIST_DIRECTORY, PLAYLIST_EXTENSION, on_edit_file_selected);
+    }
+}
+
+static void subghz_playlist_creator_text_input_callback(void* context) {
+    SubGhzPlaylistCreator* app = context;
+    if(strlen(app->text_buffer) == 0) {
+        show_popup(app, "Error", "Name cannot be empty");
+        return;
+    }
+    furi_string_set_str(app->playlist_name, app->text_buffer);
+    furi_string_printf(app->playlist_path, "%s/%s%s", PLAYLIST_DIRECTORY, app->text_buffer, PLAYLIST_EXTENSION);
+    File* file = storage_file_alloc(app->storage);
+    bool exists = storage_file_exists(app->storage, furi_string_get_cstr(app->playlist_path));
+    storage_file_free(file);
+    if(exists) {
+        dialog_ex_set_header(app->dialog, "File exists", 64, 0, AlignCenter, AlignTop);
+        dialog_ex_set_text(app->dialog, "Overwrite?", 64, 32, AlignCenter, AlignCenter);
+        dialog_ex_set_left_button_text(app->dialog, "No");
+        dialog_ex_set_right_button_text(app->dialog, "Yes");
+        dialog_ex_set_result_callback(app->dialog, subghz_playlist_creator_dialog_callback);
+        dialog_ex_set_context(app->dialog, app);
+        scene_dialog_show(app);
+    } else {
+        create_playlist_file(app);
+    }
+}
+
+SubGhzPlaylistCreator* subghz_playlist_creator_alloc(void) {
+    SubGhzPlaylistCreator* app = malloc(sizeof(SubGhzPlaylistCreator));
+    FURI_LOG_D(TAG, "Allocating app");
+    if(!app) {
+        FURI_LOG_E(TAG, "Failed to allocate app");
+        return NULL;
+    }
+    app->gui = furi_record_open(RECORD_GUI);
+    FURI_LOG_D(TAG, "Opened GUI record: %p", app->gui);
+    app->storage = furi_record_open(RECORD_STORAGE);
+    FURI_LOG_D(TAG, "Opened Storage record: %p", app->storage);
+    app->view_dispatcher = view_dispatcher_alloc();
+    FURI_LOG_D(TAG, "Allocated view dispatcher: %p", app->view_dispatcher);
+    app->submenu = submenu_alloc();
+    FURI_LOG_D(TAG, "Allocated submenu: %p", app->submenu);
+    app->playlist_edit_submenu = submenu_alloc();
+    FURI_LOG_D(TAG, "Allocated playlist_edit_submenu: %p", app->playlist_edit_submenu);
+    app->popup = popup_alloc();
+    FURI_LOG_D(TAG, "Allocated popup: %p", app->popup);
+    app->text_input = text_input_alloc();
+    FURI_LOG_D(TAG, "Allocated text input: %p", app->text_input);
+    app->dialog = dialog_ex_alloc();
+    FURI_LOG_D(TAG, "Allocated dialog: %p", app->dialog);
+    app->file_browser_result = furi_string_alloc();
+    FURI_LOG_D(TAG, "Allocated file browser result string: %p", app->file_browser_result);
+    app->file_browser = file_browser_alloc(app->file_browser_result);
+    FURI_LOG_D(TAG, "Allocated file browser: %p", app->file_browser);
+    app->playlist_name = furi_string_alloc();
+    FURI_LOG_D(TAG, "Allocated playlist name string: %p", app->playlist_name);
+    app->playlist_path = furi_string_alloc();
+    FURI_LOG_D(TAG, "Allocated playlist path string: %p", app->playlist_path);
+    memset(app->text_buffer, 0, MAX_TEXT_LENGTH);
+    app->is_stopped = false;
+    app->current_view = SubGhzPlaylistCreatorViewSubmenu;
+
+    // Initialize all views and add them to the dispatcher
+    scene_menu_init_view(app);
+    FURI_LOG_D(TAG, "Initialized menu view");
+    scene_playlist_edit_init_view(app);
+    FURI_LOG_D(TAG, "Initialized playlist edit view");
+    scene_file_browser_init_view(app);
+    FURI_LOG_D(TAG, "Initialized file browser view");
+    scene_text_input_init_view(app);
+    FURI_LOG_D(TAG, "Initialized text input view");
+    scene_dialog_init_view(app);
+    FURI_LOG_D(TAG, "Initialized dialog view");
+
+    // Add views to the dispatcher
+    FURI_LOG_D(TAG, "Adding submenu view %p with ID %lu", submenu_get_view(app->submenu), (uint32_t)SubGhzPlaylistCreatorViewSubmenu);
+    view_dispatcher_add_view(app->view_dispatcher, SubGhzPlaylistCreatorViewSubmenu, submenu_get_view(app->submenu));
+    FURI_LOG_D(TAG, "Adding playlist_edit_submenu view %p with ID %lu", submenu_get_view(app->playlist_edit_submenu), (uint32_t)SubGhzPlaylistCreatorViewPlaylistEdit);
+    view_dispatcher_add_view(app->view_dispatcher, SubGhzPlaylistCreatorViewPlaylistEdit, submenu_get_view(app->playlist_edit_submenu));
+    FURI_LOG_D(TAG, "Adding popup view %p with ID %lu", popup_get_view(app->popup), (uint32_t)SubGhzPlaylistCreatorViewPopup);
+    view_dispatcher_add_view(app->view_dispatcher, SubGhzPlaylistCreatorViewPopup, popup_get_view(app->popup));
+    FURI_LOG_D(TAG, "Adding text input view %p with ID %lu", text_input_get_view(app->text_input), (uint32_t)SubGhzPlaylistCreatorViewTextInput);
+    view_dispatcher_add_view(app->view_dispatcher, SubGhzPlaylistCreatorViewTextInput, text_input_get_view(app->text_input));
+    FURI_LOG_D(TAG, "Adding dialog view %p with ID %lu", dialog_ex_get_view(app->dialog), (uint32_t)SubGhzPlaylistCreatorViewDialog);
+    view_dispatcher_add_view(app->view_dispatcher, SubGhzPlaylistCreatorViewDialog, dialog_ex_get_view(app->dialog));
+    FURI_LOG_D(TAG, "Adding file browser view %p with ID %lu", file_browser_get_view(app->file_browser), (uint32_t)SubGhzPlaylistCreatorViewFileBrowser);
+    view_dispatcher_add_view(app->view_dispatcher, SubGhzPlaylistCreatorViewFileBrowser, file_browser_get_view(app->file_browser));
+
+    // Set initial scene
+    scene_menu_show(app);
+    FURI_LOG_D(TAG, "Showing menu scene");
+
+    view_dispatcher_set_event_callback_context(app->view_dispatcher, app);
+    view_dispatcher_set_custom_event_callback(app->view_dispatcher, subghz_playlist_creator_custom_callback);
+    view_dispatcher_set_navigation_event_callback(app->view_dispatcher, subghz_playlist_creator_back_event_callback);
+    popup_set_header(app->popup, "SubGhz Playlist Creator", 64, 26, AlignCenter, AlignCenter);
+    FURI_LOG_D(TAG, "Set popup header");
+    popup_set_text(app->popup, "Welcome!", 64, 40, AlignCenter, AlignCenter);
+    FURI_LOG_D(TAG, "Set popup text");
+    text_input_set_header_text(app->text_input, "Enter playlist name");
+    FURI_LOG_D(TAG, "Set text input header");
+    text_input_set_result_callback(
+        app->text_input,
+        subghz_playlist_creator_text_input_callback,
+        app,
+        app->text_buffer,
+        MAX_TEXT_LENGTH,
+        true);
+    FURI_LOG_D(TAG, "Set text input result callback");
+    submenu_add_item(app->submenu, "Create", 0, subghz_playlist_creator_submenu_callback, app);
+    FURI_LOG_D(TAG, "Added submenu item 0");
+    submenu_add_item(app->submenu, "Edit", 1, subghz_playlist_creator_submenu_callback, app);
+    FURI_LOG_D(TAG, "Added submenu item 1");
+    submenu_add_item(app->submenu, "", 99, NULL, NULL); // blank line
+    FURI_LOG_D(TAG, "Added submenu item 99");
+    submenu_add_item(app->submenu, "Exit", 2, subghz_playlist_creator_submenu_callback, app);
+    FURI_LOG_D(TAG, "Added submenu item 2");
+
+    return app;
+}
+
+void subghz_playlist_creator_free(SubGhzPlaylistCreator* app) {
+    furi_assert(app);
+    FURI_LOG_D(TAG, "Freeing app");
+    view_dispatcher_remove_view(app->view_dispatcher, SubGhzPlaylistCreatorViewSubmenu);
+    FURI_LOG_D(TAG, "Removed submenu view");
+    view_dispatcher_remove_view(app->view_dispatcher, SubGhzPlaylistCreatorViewPlaylistEdit);
+    FURI_LOG_D(TAG, "Removed playlist_edit_submenu view");
+    view_dispatcher_remove_view(app->view_dispatcher, SubGhzPlaylistCreatorViewPopup);
+    FURI_LOG_D(TAG, "Removed popup view");
+    view_dispatcher_remove_view(app->view_dispatcher, SubGhzPlaylistCreatorViewTextInput);
+    FURI_LOG_D(TAG, "Removed text input view");
+    view_dispatcher_remove_view(app->view_dispatcher, SubGhzPlaylistCreatorViewDialog);
+    FURI_LOG_D(TAG, "Removed dialog view");
+    view_dispatcher_remove_view(app->view_dispatcher, SubGhzPlaylistCreatorViewFileBrowser);
+    FURI_LOG_D(TAG, "Removed file browser view");
+    // Remove playlist edit view if allocated
+    if(app->playlist_edit_list) {
+        view_dispatcher_remove_view(app->view_dispatcher, SubGhzPlaylistCreatorViewPlaylistEdit);
+        variable_item_list_free(app->playlist_edit_list);
+        app->playlist_edit_list = NULL;
+    }
+    view_dispatcher_free(app->view_dispatcher);
+    FURI_LOG_D(TAG, "Freed view dispatcher");
+    submenu_free(app->submenu);
+    FURI_LOG_D(TAG, "Freed submenu");
+    submenu_free(app->playlist_edit_submenu);
+    FURI_LOG_D(TAG, "Freed playlist_edit_submenu");
+    popup_free(app->popup);
+    FURI_LOG_D(TAG, "Freed popup");
+    text_input_free(app->text_input);
+    FURI_LOG_D(TAG, "Freed text input");
+    dialog_ex_free(app->dialog);
+    FURI_LOG_D(TAG, "Freed dialog");
+    file_browser_free(app->file_browser);
+    FURI_LOG_D(TAG, "Freed file browser");
+    furi_string_free(app->file_browser_result);
+    FURI_LOG_D(TAG, "Freed file browser result string");
+    furi_string_free(app->playlist_name);
+    FURI_LOG_D(TAG, "Freed playlist name string");
+    furi_string_free(app->playlist_path);
+    FURI_LOG_D(TAG, "Freed playlist path string");
+    // Free playlist entries
+    if(app->playlist_entries) {
+        for(size_t i = 0; i < app->playlist_entry_count; ++i) {
+            free(app->playlist_entries[i]);
+        }
+        free(app->playlist_entries);
+        app->playlist_entries = NULL;
+        app->playlist_entry_count = 0;
+        app->playlist_entry_capacity = 0;
+    }
+    furi_record_close(RECORD_GUI);
+    FURI_LOG_D(TAG, "Closed GUI record");
+    furi_record_close(RECORD_STORAGE);
+    FURI_LOG_D(TAG, "Closed Storage record");
+    free(app);
+    FURI_LOG_D(TAG, "Freed app");
+}
+
+bool subghz_playlist_creator_custom_callback(void* context, uint32_t custom_event) {
+    UNUSED(context);
+    // The custom callback is now primarily for the timer event
+    SubGhzPlaylistCreator* app = context;
+
+    if (custom_event == SubGhzPlaylistCreatorCustomEventShowMenu) {
+        FURI_LOG_D(TAG, "Received custom event to show menu");
+        // Stop the timer once the event is received
+        furi_timer_stop(app->popup_timer);
+        furi_timer_free(app->popup_timer);
+        app->popup_timer = NULL;
+        // Switch to the main menu scene
+        scene_menu_show(app);
+        return true;
+    }
+
+    return false;
+}
+
+// Replace the back event callback
+bool subghz_playlist_creator_back_event_callback(void* context) {
+    SubGhzPlaylistCreator* app = context;
+    FURI_LOG_D(TAG, "Back event callback. is_stopped: %d, current_view: %d", app->is_stopped, app->current_view);
+    if(app->is_stopped) return true;
+    // If in PlaylistEdit, show discard dialog
+    if(app->current_view == SubGhzPlaylistCreatorViewPlaylistEdit) {
+        scene_playlist_edit_back_event_callback(app);
+        return true;
+    }
+    // If not in main menu, go to main menu
+    if(app->current_view != SubGhzPlaylistCreatorViewSubmenu) {
+        FURI_LOG_D(TAG, "Switching to menu scene from view: %d", app->current_view);
+        scene_menu_show(app);
+        return true;
+    }
+    FURI_LOG_D(TAG, "Stopping view dispatcher");
+    app->is_stopped = true;
+    view_dispatcher_stop(app->view_dispatcher);
+    return true;
+}
+
+int32_t subghz_playlist_creator_app(void* p) {
+    UNUSED(p);
+    FURI_LOG_D(TAG, "App starting");
+    SubGhzPlaylistCreator* app = subghz_playlist_creator_alloc();
+    // Allocate the timer here as part of the app struct
+    app->popup_timer = NULL;
+    if(!app) {
+        FURI_LOG_E(TAG, "App allocation failed, exiting");
+        return 255;
+    }
+    FURI_LOG_D(TAG, "App allocated: %p", app);
+    view_dispatcher_attach_to_gui(app->view_dispatcher, app->gui, ViewDispatcherTypeFullscreen);
+    FURI_LOG_D(TAG, "View dispatcher attached to GUI");
+
+    // Set initial scene to menu immediately after attaching dispatcher
+    scene_menu_show(app);
+    FURI_LOG_D(TAG, "Showing menu scene immediately after attach");
+
+    view_dispatcher_run(app->view_dispatcher);
+    FURI_LOG_D(TAG, "View dispatcher stopped, freeing app");
+    subghz_playlist_creator_free(app);
+    FURI_LOG_D(TAG, "App freeing complete, exiting");
+    return 0;
+}

+ 81 - 0
subghz_playlist_creator/subghz_playlist_creator.h

@@ -0,0 +1,81 @@
+#pragma once
+
+#include <furi.h>
+#include <gui/gui.h>
+#include <gui/view.h>
+#include <gui/view_dispatcher.h>
+#include <gui/scene_manager.h>
+#include <gui/modules/submenu.h>
+#include <gui/modules/popup.h>
+#include <gui/modules/text_input.h>
+#include <gui/modules/dialog_ex.h>
+#include <gui/modules/file_browser.h>
+#include <storage/storage.h>
+#define MAX_TEXT_LENGTH 128
+
+// Forward declare struct for callback
+struct SubGhzPlaylistCreator;
+typedef void (*SceneFileBrowserSelectCallback)(struct SubGhzPlaylistCreator* app, const char* path);
+
+// Forward declare VariableItemList and DialogEx for struct members.
+struct VariableItemList;
+struct DialogEx;
+
+// Add ReturnScene enum before struct definition
+typedef enum {
+    ReturnScene_None = 0,
+    ReturnScene_Menu,
+    ReturnScene_PlaylistEdit,
+    ReturnScene_TextInput,
+} ReturnScene;
+
+typedef struct SubGhzPlaylistCreator {
+    Gui* gui;
+    Storage* storage;
+    ViewDispatcher* view_dispatcher;
+    Submenu* submenu;
+    Submenu* playlist_edit_submenu;
+    Popup* popup;
+    TextInput* text_input;
+    DialogEx* dialog;
+    FileBrowser* file_browser;
+    FuriString* file_browser_result;
+    FuriString* playlist_name;
+    FuriString* playlist_path;
+    char text_buffer[MAX_TEXT_LENGTH];
+    SceneFileBrowserSelectCallback file_browser_select_cb;
+    int current_view;
+    // Playlist editing scene state
+    char** playlist_entries;
+    size_t playlist_entry_count;
+    size_t playlist_entry_capacity;
+    bool playlist_modified;
+    bool is_stopped;
+    FuriTimer* popup_timer;
+    struct VariableItemList* playlist_edit_list;
+    ReturnScene return_scene; // Add this field
+} SubGhzPlaylistCreator;
+
+
+typedef enum {
+    SubGhzPlaylistCreatorViewSubmenu,
+    SubGhzPlaylistCreatorViewPopup,
+    SubGhzPlaylistCreatorViewTextInput,
+    SubGhzPlaylistCreatorViewDialog,
+    SubGhzPlaylistCreatorViewFileBrowser,
+    SubGhzPlaylistCreatorViewPlaylistEdit,
+} SubGhzPlaylistCreatorView;
+
+// Custom events
+typedef enum {
+    SubGhzPlaylistCreatorCustomEventShowMenu = 100, // Start custom events from 100 or higher
+} SubGhzPlaylistCreatorCustomEvent;
+
+// Function declarations
+SubGhzPlaylistCreator* subghz_playlist_creator_alloc(void);
+void subghz_playlist_creator_free(SubGhzPlaylistCreator* app);
+bool subghz_playlist_creator_custom_callback(void* context, uint32_t custom_event);
+bool subghz_playlist_creator_back_event_callback(void* context);
+int32_t subghz_playlist_creator_app(void* p);
+// Playlist editing scene
+void scene_playlist_edit_show(SubGhzPlaylistCreator* app);

TEMPAT SAMPAH
subghz_playlist_creator/subghz_playlist_creator.png